From 8c3cdc60b86bfc058c23c53c3a0559ef783ee074 Mon Sep 17 00:00:00 2001 From: ccurme Date: Wed, 8 Jan 2025 11:36:38 -0500 Subject: [PATCH 1/5] openai[patch]: remove optional defaults (#29097) Merging into v0.3 branch --- .../langchain_openai/chat_models/azure.py | 10 ++++++---- .../langchain_openai/chat_models/base.py | 20 ++++++++++--------- .../__snapshots__/test_azure_standard.ambr | 1 - .../__snapshots__/test_base_standard.ambr | 1 - .../tests/unit_tests/chat_models/test_base.py | 2 -- libs/partners/xai/Makefile | 3 +++ .../partners/xai/langchain_xai/chat_models.py | 7 ++++--- .../test_chat_models_standard.ambr | 1 - 8 files changed, 24 insertions(+), 21 deletions(-) diff --git a/libs/partners/openai/langchain_openai/chat_models/azure.py b/libs/partners/openai/langchain_openai/chat_models/azure.py index 2e1e5f8abfe03..f45df50f7d748 100644 --- a/libs/partners/openai/langchain_openai/chat_models/azure.py +++ b/libs/partners/openai/langchain_openai/chat_models/azure.py @@ -79,7 +79,7 @@ class AzureChatOpenAI(BaseChatOpenAI): https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#rest-api-versioning timeout: Union[float, Tuple[float, float], Any, None] Timeout for requests. - max_retries: int + max_retries: Optional[int] Max number of retries. organization: Optional[str] OpenAI organization ID. If not passed in will be read from env @@ -586,9 +586,9 @@ def is_lc_serializable(cls) -> bool: @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key and python package exists in environment.""" - if self.n < 1: + if self.n is not None and self.n < 1: raise ValueError("n must be at least 1.") - if self.n > 1 and self.streaming: + elif self.n is not None and self.n > 1 and self.streaming: raise ValueError("n must be 1 when streaming.") if self.disabled_params is None: @@ -641,10 +641,12 @@ def validate_environment(self) -> Self: "organization": self.openai_organization, "base_url": self.openai_api_base, "timeout": self.request_timeout, - "max_retries": self.max_retries, "default_headers": self.default_headers, "default_query": self.default_query, } + if self.max_retries is not None: + client_params["max_retries"] = self.max_retries + if not self.client: sync_specific = {"http_client": self.http_client} self.root_client = openai.AzureOpenAI(**client_params, **sync_specific) # type: ignore[arg-type] diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 142e7eca1a84b..546a33c720e8b 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -409,7 +409,7 @@ class BaseChatOpenAI(BaseChatModel): root_async_client: Any = Field(default=None, exclude=True) #: :meta private: model_name: str = Field(default="gpt-3.5-turbo", alias="model") """Model name to use.""" - temperature: float = 0.7 + temperature: Optional[float] = None """What sampling temperature to use.""" model_kwargs: Dict[str, Any] = Field(default_factory=dict) """Holds any model parameters valid for `create` call not explicitly specified.""" @@ -430,7 +430,7 @@ class BaseChatOpenAI(BaseChatModel): ) """Timeout for requests to OpenAI completion API. Can be float, httpx.Timeout or None.""" - max_retries: int = 2 + max_retries: Optional[int] = None """Maximum number of retries to make when generating.""" presence_penalty: Optional[float] = None """Penalizes repeated tokens.""" @@ -448,7 +448,7 @@ class BaseChatOpenAI(BaseChatModel): """Modify the likelihood of specified tokens appearing in the completion.""" streaming: bool = False """Whether to stream the results or not.""" - n: int = 1 + n: Optional[int] = None """Number of chat completions to generate for each prompt.""" top_p: Optional[float] = None """Total probability mass of tokens to consider at each step.""" @@ -532,9 +532,9 @@ def validate_temperature(cls, values: Dict[str, Any]) -> Any: @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key and python package exists in environment.""" - if self.n < 1: + if self.n is not None and self.n < 1: raise ValueError("n must be at least 1.") - if self.n > 1 and self.streaming: + elif self.n is not None and self.n > 1 and self.streaming: raise ValueError("n must be 1 when streaming.") # Check OPENAI_ORGANIZATION for backwards compatibility. @@ -551,10 +551,12 @@ def validate_environment(self) -> Self: "organization": self.openai_organization, "base_url": self.openai_api_base, "timeout": self.request_timeout, - "max_retries": self.max_retries, "default_headers": self.default_headers, "default_query": self.default_query, } + if self.max_retries is not None: + client_params["max_retries"] = self.max_retries + if self.openai_proxy and (self.http_client or self.http_async_client): openai_proxy = self.openai_proxy http_client = self.http_client @@ -609,14 +611,14 @@ def _default_params(self) -> Dict[str, Any]: "stop": self.stop or None, # also exclude empty list for this "max_tokens": self.max_tokens, "extra_body": self.extra_body, + "n": self.n, + "temperature": self.temperature, "reasoning_effort": self.reasoning_effort, } params = { "model": self.model_name, "stream": self.streaming, - "n": self.n, - "temperature": self.temperature, **{k: v for k, v in exclude_if_none.items() if v is not None}, **self.model_kwargs, } @@ -1565,7 +1567,7 @@ class ChatOpenAI(BaseChatOpenAI): # type: ignore[override] timeout: Union[float, Tuple[float, float], Any, None] Timeout for requests. - max_retries: int + max_retries: Optional[int] Max number of retries. api_key: Optional[str] OpenAI API key. If not passed in will be read from env var OPENAI_API_KEY. diff --git a/libs/partners/openai/tests/unit_tests/chat_models/__snapshots__/test_azure_standard.ambr b/libs/partners/openai/tests/unit_tests/chat_models/__snapshots__/test_azure_standard.ambr index 2b8c3563b9443..2060512958a9f 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/__snapshots__/test_azure_standard.ambr +++ b/libs/partners/openai/tests/unit_tests/chat_models/__snapshots__/test_azure_standard.ambr @@ -15,7 +15,6 @@ }), 'max_retries': 2, 'max_tokens': 100, - 'n': 1, 'openai_api_key': dict({ 'id': list([ 'AZURE_OPENAI_API_KEY', diff --git a/libs/partners/openai/tests/unit_tests/chat_models/__snapshots__/test_base_standard.ambr b/libs/partners/openai/tests/unit_tests/chat_models/__snapshots__/test_base_standard.ambr index b7ab1ce9c072c..e7307c6158fbc 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/__snapshots__/test_base_standard.ambr +++ b/libs/partners/openai/tests/unit_tests/chat_models/__snapshots__/test_base_standard.ambr @@ -11,7 +11,6 @@ 'max_retries': 2, 'max_tokens': 100, 'model_name': 'gpt-3.5-turbo', - 'n': 1, 'openai_api_key': dict({ 'id': list([ 'OPENAI_API_KEY', diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index 2e6cca0cd2d96..5eac32c0447dd 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py @@ -877,8 +877,6 @@ def test__get_request_payload() -> None: ], "model": "gpt-4o-2024-08-06", "stream": False, - "n": 1, - "temperature": 0.7, } payload = llm._get_request_payload(messages) assert payload == expected diff --git a/libs/partners/xai/Makefile b/libs/partners/xai/Makefile index 963563e2d7099..6859cc789a179 100644 --- a/libs/partners/xai/Makefile +++ b/libs/partners/xai/Makefile @@ -11,6 +11,9 @@ integration_test integration_tests: TEST_FILE=tests/integration_tests/ test tests: poetry run pytest --disable-socket --allow-unix-socket $(TEST_FILE) +test_watch: + poetry run ptw --snapshot-update --now . -- -vv $(TEST_FILE) + integration_test integration_tests: poetry run pytest $(TEST_FILE) diff --git a/libs/partners/xai/langchain_xai/chat_models.py b/libs/partners/xai/langchain_xai/chat_models.py index 775d22740cd4e..a854be5487d4c 100644 --- a/libs/partners/xai/langchain_xai/chat_models.py +++ b/libs/partners/xai/langchain_xai/chat_models.py @@ -320,9 +320,9 @@ def _get_ls_params( @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key and python package exists in environment.""" - if self.n < 1: + if self.n is not None and self.n < 1: raise ValueError("n must be at least 1.") - if self.n > 1 and self.streaming: + if self.n is not None and self.n > 1 and self.streaming: raise ValueError("n must be 1 when streaming.") client_params: dict = { @@ -331,10 +331,11 @@ def validate_environment(self) -> Self: ), "base_url": self.xai_api_base, "timeout": self.request_timeout, - "max_retries": self.max_retries, "default_headers": self.default_headers, "default_query": self.default_query, } + if self.max_retries is not None: + client_params["max_retries"] = self.max_retries if client_params["api_key"] is None: raise ValueError( diff --git a/libs/partners/xai/tests/unit_tests/__snapshots__/test_chat_models_standard.ambr b/libs/partners/xai/tests/unit_tests/__snapshots__/test_chat_models_standard.ambr index 5c6f113f2174a..4cd1261555c90 100644 --- a/libs/partners/xai/tests/unit_tests/__snapshots__/test_chat_models_standard.ambr +++ b/libs/partners/xai/tests/unit_tests/__snapshots__/test_chat_models_standard.ambr @@ -10,7 +10,6 @@ 'max_retries': 2, 'max_tokens': 100, 'model_name': 'grok-beta', - 'n': 1, 'request_timeout': 60.0, 'stop': list([ ]), From 23fdc65ae5e061ec00642236a5fac652ecb2e43d Mon Sep 17 00:00:00 2001 From: Jacob Lee Date: Thu, 9 Jan 2025 11:16:05 -0800 Subject: [PATCH 2/5] feat(openai): Update with_structured_output default for OpenAI (#28947) Should be accompanied by a minor bump Should we also set `strict=True` by default? --------- Co-authored-by: Bagatur Co-authored-by: ccurme --- .../langchain_openai/chat_models/azure.py | 293 +++++++++- .../langchain_openai/chat_models/base.py | 529 +++++++++++------- .../chat_models/test_azure_standard.py | 4 + .../chat_models/test_base.py | 46 +- .../integration_tests/chat_models.py | 59 +- .../langchain_tests/unit_tests/chat_models.py | 18 + 6 files changed, 714 insertions(+), 235 deletions(-) diff --git a/libs/partners/openai/langchain_openai/chat_models/azure.py b/libs/partners/openai/langchain_openai/chat_models/azure.py index f45df50f7d748..57e053ecd1253 100644 --- a/libs/partners/openai/langchain_openai/chat_models/azure.py +++ b/libs/partners/openai/langchain_openai/chat_models/azure.py @@ -18,13 +18,15 @@ ) import openai +from langchain_core.language_models import LanguageModelInput from langchain_core.language_models.chat_models import LangSmithParams from langchain_core.messages import BaseMessage from langchain_core.outputs import ChatResult +from langchain_core.runnables import Runnable from langchain_core.utils import from_env, secret_from_env from langchain_core.utils.pydantic import is_basemodel_subclass from pydantic import BaseModel, Field, SecretStr, model_validator -from typing_extensions import Self +from typing_extensions import Literal, Self from langchain_openai.chat_models.base import BaseChatOpenAI @@ -739,3 +741,292 @@ def _create_chat_result( ) return chat_result + + def with_structured_output( + self, + schema: Optional[_DictOrPydanticClass] = None, + *, + method: Literal["function_calling", "json_mode", "json_schema"] = "json_schema", + include_raw: bool = False, + strict: Optional[bool] = None, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, _DictOrPydantic]: + """Model wrapper that returns outputs formatted to match the given schema. + + Args: + schema: + The output schema. Can be passed in as: + + - a JSON Schema, + - a TypedDict class, + - or a Pydantic class, + - an OpenAI function/tool schema. + + If ``schema`` is a Pydantic class then the model output will be a + Pydantic instance of that class, and the model-generated fields will be + validated by the Pydantic class. Otherwise the model output will be a + dict and will not be validated. See :meth:`langchain_core.utils.function_calling.convert_to_openai_tool` + for more on how to properly specify types and descriptions of + schema fields when specifying a Pydantic or TypedDict class. + + method: The method for steering model generation, one of: + + - "json_schema": + Uses OpenAI's Structured Output API: + https://platform.openai.com/docs/guides/structured-outputs + Supported for "gpt-4o-mini", "gpt-4o-2024-08-06", "o1", and later + models. + - "function_calling": + Uses OpenAI's tool-calling (formerly called function calling) + API: https://platform.openai.com/docs/guides/function-calling + - "json_mode": + Uses OpenAI's JSON mode. Note that if using JSON mode then you + must include instructions for formatting the output into the + desired schema into the model call: + https://platform.openai.com/docs/guides/structured-outputs/json-mode + + Learn more about the differences between the methods and which models + support which methods here: + + - https://platform.openai.com/docs/guides/structured-outputs/structured-outputs-vs-json-mode + - https://platform.openai.com/docs/guides/structured-outputs/function-calling-vs-response-format + + include_raw: + If False then only the parsed structured output is returned. If + an error occurs during model output parsing it will be raised. If True + then both the raw model response (a BaseMessage) and the parsed model + response will be returned. If an error occurs during output parsing it + will be caught and returned as well. The final output is always a dict + with keys "raw", "parsed", and "parsing_error". + strict: + + - True: + Model output is guaranteed to exactly match the schema. + The input schema will also be validated according to + https://platform.openai.com/docs/guides/structured-outputs/supported-schemas + - False: + Input schema will not be validated and model output will not be + validated. + - None: + ``strict`` argument will not be passed to the model. + + Defaults to False if ``method`` is ``"json_schema"`` or + ``"function_calling"``. Can only be non-null if ``method`` is + ``"json_schema"`` or ``"function_calling"``. + + kwargs: Additional keyword args aren't supported. + + Returns: + A Runnable that takes same inputs as a :class:`langchain_core.language_models.chat.BaseChatModel`. + + | If ``include_raw`` is False and ``schema`` is a Pydantic class, Runnable outputs an instance of ``schema`` (i.e., a Pydantic object). Otherwise, if ``include_raw`` is False then Runnable outputs a dict. + + | If ``include_raw`` is True, then Runnable outputs a dict with keys: + + - "raw": BaseMessage + - "parsed": None if there was a parsing error, otherwise the type depends on the ``schema`` as described above. + - "parsing_error": Optional[BaseException] + + .. versionchanged:: 0.1.20 + + Added support for TypedDict class ``schema``. + + .. versionchanged:: 0.1.21 + + Support for ``strict`` argument added. + Support for ``method`` = "json_schema" added. + + .. versionchanged:: 0.3.0 + + - ``method`` default changed from "function_calling" to "json_schema". + - ``strict`` defaults to True instead of False when ``method`` is + "function_calling". + + .. dropdown:: Example: schema=Pydantic class, method="json_schema", include_raw=False, strict=True + + Note, OpenAI has a number of restrictions on what types of schemas can be + provided if ``strict`` = True. When using Pydantic, our model cannot + specify any Field metadata (like min/max constraints) and fields cannot + have default values. + + See all constraints here: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas + + .. code-block:: python + + from typing import Optional + + from langchain_openai import AzureChatOpenAI + from pydantic import BaseModel, Field + + + class AnswerWithJustification(BaseModel): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: Optional[str] = Field( + default=..., description="A justification for the answer." + ) + + + llm = AzureChatOpenAI(azure_deployment="...", model="gpt-4o", temperature=0) + structured_llm = llm.with_structured_output(AnswerWithJustification) + + structured_llm.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + + # -> AnswerWithJustification( + # answer='They weigh the same', + # justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.' + # ) + + .. dropdown:: Example: schema=Pydantic class, method="json_schema", include_raw=True + + .. code-block:: python + + from langchain_openai import AzureChatOpenAI + from pydantic import BaseModel + + + class AnswerWithJustification(BaseModel): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: str + + + llm = AzureChatOpenAI(azure_deployment="...", model="gpt-4o", temperature=0) + structured_llm = llm.with_structured_output( + AnswerWithJustification, include_raw=True + ) + + structured_llm.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + # -> { + # 'raw': AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_Ao02pnFYXD6GN1yzc0uXPsvF', 'function': {'arguments': '{"answer":"They weigh the same.","justification":"Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ."}', 'name': 'AnswerWithJustification'}, 'type': 'function'}]}), + # 'parsed': AnswerWithJustification(answer='They weigh the same.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.'), + # 'parsing_error': None + # } + + .. dropdown:: Example: schema=TypedDict class, method="json_schema", include_raw=False + + .. code-block:: python + + from typing_extensions import Annotated, TypedDict + + from langchain_openai import AzureChatOpenAI + + + class AnswerWithJustification(TypedDict): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: Annotated[ + Optional[str], None, "A justification for the answer." + ] + + + llm = AzureChatOpenAI(azure_deployment="...", model="gpt-4o", temperature=0) + structured_llm = llm.with_structured_output(AnswerWithJustification) + + structured_llm.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + # -> { + # 'answer': 'They weigh the same', + # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.' + # } + + .. dropdown:: Example: schema=OpenAI function schema, method="json_schema", include_raw=False + + .. code-block:: python + + from langchain_openai import AzureChatOpenAI + + oai_schema = { + 'name': 'AnswerWithJustification', + 'description': 'An answer to the user question along with justification for the answer.', + 'parameters': { + 'type': 'object', + 'properties': { + 'answer': {'type': 'string'}, + 'justification': {'description': 'A justification for the answer.', 'type': 'string'} + }, + 'required': ['answer'] + } + } + + llm = AzureChatOpenAI( + azure_deployment="...", + model="gpt-4o", + temperature=0, + ) + structured_llm = llm.with_structured_output(oai_schema) + + structured_llm.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + # -> { + # 'answer': 'They weigh the same', + # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.' + # } + + .. dropdown:: Example: schema=Pydantic class, method="json_mode", include_raw=True + + .. code-block:: + + from langchain_openai import AzureChatOpenAI + from pydantic import BaseModel + + class AnswerWithJustification(BaseModel): + answer: str + justification: str + + llm = AzureChatOpenAI( + azure_deployment="...", + model="gpt-4o", + temperature=0, + ) + structured_llm = llm.with_structured_output( + AnswerWithJustification, + method="json_mode", + include_raw=True + ) + + structured_llm.invoke( + "Answer the following question. " + "Make sure to return a JSON blob with keys 'answer' and 'justification'.\\n\\n" + "What's heavier a pound of bricks or a pound of feathers?" + ) + # -> { + # 'raw': AIMessage(content='{\\n "answer": "They are both the same weight.",\\n "justification": "Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight." \\n}'), + # 'parsed': AnswerWithJustification(answer='They are both the same weight.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight.'), + # 'parsing_error': None + # } + + .. dropdown:: Example: schema=None, method="json_mode", include_raw=True + + .. code-block:: + + structured_llm = llm.with_structured_output(method="json_mode", include_raw=True) + + structured_llm.invoke( + "Answer the following question. " + "Make sure to return a JSON blob with keys 'answer' and 'justification'.\\n\\n" + "What's heavier a pound of bricks or a pound of feathers?" + ) + # -> { + # 'raw': AIMessage(content='{\\n "answer": "They are both the same weight.",\\n "justification": "Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight." \\n}'), + # 'parsed': { + # 'answer': 'They are both the same weight.', + # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight.' + # }, + # 'parsing_error': None + # } + """ # noqa: E501 + if method in ("json_schema", "function_calling") and strict is None: + strict = False + return super().with_structured_output( + schema, method=method, include_raw=include_raw, strict=strict, **kwargs + ) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 999935b411184..cdbcb76871fa8 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -92,6 +92,7 @@ ) from langchain_core.utils.utils import _build_model_kwargs, from_env, secret_from_env from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator +from pydantic.v1 import BaseModel as BaseModelV1 from typing_extensions import Self logger = logging.getLogger(__name__) @@ -393,6 +394,21 @@ def _update_token_usage( return new_usage +def _handle_openai_bad_request(e: openai.BadRequestError) -> None: + if ( + "'response_format' of type 'json_schema' is not supported with this model" + ) in e.message: + raise ValueError( + "This model does not support OpenAI's structured output " + "feature, which is the default method for " + "`with_structured_output` as of langchain-openai==0.3. To use " + "`with_structured_output` with this model, specify " + '`method="function_calling"`.' + ) + else: + raise + + class _FunctionCall(TypedDict): name: str @@ -737,7 +753,10 @@ def _generate( "specified." ) payload.pop("stream") - response = self.root_client.beta.chat.completions.parse(**payload) + try: + response = self.root_client.beta.chat.completions.parse(**payload) + except openai.BadRequestError as e: + _handle_openai_bad_request(e) elif self.include_response_headers: raw_response = self.client.with_raw_response.create(**payload) response = raw_response.parse() @@ -897,9 +916,12 @@ async def _agenerate( "specified." ) payload.pop("stream") - response = await self.root_async_client.beta.chat.completions.parse( - **payload - ) + try: + response = await self.root_async_client.beta.chat.completions.parse( + **payload + ) + except openai.BadRequestError as e: + _handle_openai_bad_request(e) elif self.include_response_headers: raw_response = await self.async_client.with_raw_response.create(**payload) response = raw_response.parse() @@ -1234,13 +1256,13 @@ def with_structured_output( method: The method for steering model generation, one of: - - "function_calling": - Uses OpenAI's tool-calling (formerly called function calling) - API: https://platform.openai.com/docs/guides/function-calling - "json_schema": Uses OpenAI's Structured Output API: https://platform.openai.com/docs/guides/structured-outputs - Supported for "gpt-4o-mini", "gpt-4o-2024-08-06", and later + Supported for "gpt-4o-mini", "gpt-4o-2024-08-06", "o1", and later models. + - "function_calling": + Uses OpenAI's tool-calling (formerly called function calling) + API: https://platform.openai.com/docs/guides/function-calling - "json_mode": Uses OpenAI's JSON mode. Note that if using JSON mode then you must include instructions for formatting the output into the @@ -1272,9 +1294,9 @@ def with_structured_output( - None: ``strict`` argument will not be passed to the model. - If ``method`` is "json_schema" defaults to True. If ``method`` is - "function_calling" or "json_mode" defaults to None. Can only be - non-null if ``method`` is "function_calling" or "json_schema". + Defaults to False if ``method`` is ``"json_schema"`` or + ``"function_calling"``. Can only be non-null if ``method`` is + ``"json_schema"`` or ``"function_calling"``. kwargs: Additional keyword args aren't supported. @@ -1297,193 +1319,6 @@ def with_structured_output( Support for ``strict`` argument added. Support for ``method`` = "json_schema" added. - - .. note:: Planned breaking changes in version `0.3.0` - - - ``method`` default will be changed to "json_schema" from - "function_calling". - - ``strict`` will default to True when ``method`` is - "function_calling" as of version `0.3.0`. - - - .. dropdown:: Example: schema=Pydantic class, method="function_calling", include_raw=False, strict=True - - Note, OpenAI has a number of restrictions on what types of schemas can be - provided if ``strict`` = True. When using Pydantic, our model cannot - specify any Field metadata (like min/max constraints) and fields cannot - have default values. - - See all constraints here: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas - - .. code-block:: python - - from typing import Optional - - from langchain_openai import ChatOpenAI - from pydantic import BaseModel, Field - - - class AnswerWithJustification(BaseModel): - '''An answer to the user question along with justification for the answer.''' - - answer: str - justification: Optional[str] = Field( - default=..., description="A justification for the answer." - ) - - - llm = ChatOpenAI(model="gpt-4o", temperature=0) - structured_llm = llm.with_structured_output( - AnswerWithJustification, strict=True - ) - - structured_llm.invoke( - "What weighs more a pound of bricks or a pound of feathers" - ) - - # -> AnswerWithJustification( - # answer='They weigh the same', - # justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.' - # ) - - .. dropdown:: Example: schema=Pydantic class, method="function_calling", include_raw=True - - .. code-block:: python - - from langchain_openai import ChatOpenAI - from pydantic import BaseModel - - - class AnswerWithJustification(BaseModel): - '''An answer to the user question along with justification for the answer.''' - - answer: str - justification: str - - - llm = ChatOpenAI(model="gpt-4o", temperature=0) - structured_llm = llm.with_structured_output( - AnswerWithJustification, include_raw=True - ) - - structured_llm.invoke( - "What weighs more a pound of bricks or a pound of feathers" - ) - # -> { - # 'raw': AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_Ao02pnFYXD6GN1yzc0uXPsvF', 'function': {'arguments': '{"answer":"They weigh the same.","justification":"Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ."}', 'name': 'AnswerWithJustification'}, 'type': 'function'}]}), - # 'parsed': AnswerWithJustification(answer='They weigh the same.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.'), - # 'parsing_error': None - # } - - .. dropdown:: Example: schema=TypedDict class, method="function_calling", include_raw=False - - .. code-block:: python - - # IMPORTANT: If you are using Python <=3.8, you need to import Annotated - # from typing_extensions, not from typing. - from typing_extensions import Annotated, TypedDict - - from langchain_openai import ChatOpenAI - - - class AnswerWithJustification(TypedDict): - '''An answer to the user question along with justification for the answer.''' - - answer: str - justification: Annotated[ - Optional[str], None, "A justification for the answer." - ] - - - llm = ChatOpenAI(model="gpt-4o", temperature=0) - structured_llm = llm.with_structured_output(AnswerWithJustification) - - structured_llm.invoke( - "What weighs more a pound of bricks or a pound of feathers" - ) - # -> { - # 'answer': 'They weigh the same', - # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.' - # } - - .. dropdown:: Example: schema=OpenAI function schema, method="function_calling", include_raw=False - - .. code-block:: python - - from langchain_openai import ChatOpenAI - - oai_schema = { - 'name': 'AnswerWithJustification', - 'description': 'An answer to the user question along with justification for the answer.', - 'parameters': { - 'type': 'object', - 'properties': { - 'answer': {'type': 'string'}, - 'justification': {'description': 'A justification for the answer.', 'type': 'string'} - }, - 'required': ['answer'] - } - } - - llm = ChatOpenAI(model="gpt-4o", temperature=0) - structured_llm = llm.with_structured_output(oai_schema) - - structured_llm.invoke( - "What weighs more a pound of bricks or a pound of feathers" - ) - # -> { - # 'answer': 'They weigh the same', - # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.' - # } - - .. dropdown:: Example: schema=Pydantic class, method="json_mode", include_raw=True - - .. code-block:: - - from langchain_openai import ChatOpenAI - from pydantic import BaseModel - - class AnswerWithJustification(BaseModel): - answer: str - justification: str - - llm = ChatOpenAI(model="gpt-4o", temperature=0) - structured_llm = llm.with_structured_output( - AnswerWithJustification, - method="json_mode", - include_raw=True - ) - - structured_llm.invoke( - "Answer the following question. " - "Make sure to return a JSON blob with keys 'answer' and 'justification'.\\n\\n" - "What's heavier a pound of bricks or a pound of feathers?" - ) - # -> { - # 'raw': AIMessage(content='{\\n "answer": "They are both the same weight.",\\n "justification": "Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight." \\n}'), - # 'parsed': AnswerWithJustification(answer='They are both the same weight.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight.'), - # 'parsing_error': None - # } - - .. dropdown:: Example: schema=None, method="json_mode", include_raw=True - - .. code-block:: - - structured_llm = llm.with_structured_output(method="json_mode", include_raw=True) - - structured_llm.invoke( - "Answer the following question. " - "Make sure to return a JSON blob with keys 'answer' and 'justification'.\\n\\n" - "What's heavier a pound of bricks or a pound of feathers?" - ) - # -> { - # 'raw': AIMessage(content='{\\n "answer": "They are both the same weight.",\\n "justification": "Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight." \\n}'), - # 'parsed': { - # 'answer': 'They are both the same weight.', - # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight.' - # }, - # 'parsing_error': None - # } """ # noqa: E501 if kwargs: raise ValueError(f"Received unsupported arguments {kwargs}") @@ -1492,6 +1327,21 @@ class AnswerWithJustification(BaseModel): "Argument `strict` is not supported with `method`='json_mode'" ) is_pydantic_schema = _is_pydantic_class(schema) + + # Check for Pydantic BaseModel V1 + if ( + method == "json_schema" + and is_pydantic_schema + and issubclass(schema, BaseModelV1) # type: ignore[arg-type] + ): + warnings.warn( + "Received a Pydantic BaseModel V1 schema. This is not supported by " + 'method="json_schema". Please use method="function_calling" ' + "or specify schema via JSON Schema or Pydantic V2 BaseModel. " + 'Overriding to method="function_calling".' + ) + method = "function_calling" + if method == "function_calling": if schema is None: raise ValueError( @@ -2149,6 +1999,289 @@ async def _astream( async for chunk in super()._astream(*args, **kwargs): yield chunk + def with_structured_output( + self, + schema: Optional[_DictOrPydanticClass] = None, + *, + method: Literal["function_calling", "json_mode", "json_schema"] = "json_schema", + include_raw: bool = False, + strict: Optional[bool] = None, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, _DictOrPydantic]: + """Model wrapper that returns outputs formatted to match the given schema. + + Args: + schema: + The output schema. Can be passed in as: + + - a JSON Schema, + - a TypedDict class, + - or a Pydantic class, + - an OpenAI function/tool schema. + + If ``schema`` is a Pydantic class then the model output will be a + Pydantic instance of that class, and the model-generated fields will be + validated by the Pydantic class. Otherwise the model output will be a + dict and will not be validated. See :meth:`langchain_core.utils.function_calling.convert_to_openai_tool` + for more on how to properly specify types and descriptions of + schema fields when specifying a Pydantic or TypedDict class. + + method: The method for steering model generation, one of: + + - "json_schema": + Uses OpenAI's Structured Output API: + https://platform.openai.com/docs/guides/structured-outputs + Supported for "gpt-4o-mini", "gpt-4o-2024-08-06", and later + models. + - "function_calling": + Uses OpenAI's tool-calling (formerly called function calling) + API: https://platform.openai.com/docs/guides/function-calling + - "json_mode": + Uses OpenAI's JSON mode. Note that if using JSON mode then you + must include instructions for formatting the output into the + desired schema into the model call: + https://platform.openai.com/docs/guides/structured-outputs/json-mode + + Learn more about the differences between the methods and which models + support which methods here: + + - https://platform.openai.com/docs/guides/structured-outputs/structured-outputs-vs-json-mode + - https://platform.openai.com/docs/guides/structured-outputs/function-calling-vs-response-format + + include_raw: + If False then only the parsed structured output is returned. If + an error occurs during model output parsing it will be raised. If True + then both the raw model response (a BaseMessage) and the parsed model + response will be returned. If an error occurs during output parsing it + will be caught and returned as well. The final output is always a dict + with keys "raw", "parsed", and "parsing_error". + strict: + + - True: + Model output is guaranteed to exactly match the schema. + The input schema will also be validated according to + https://platform.openai.com/docs/guides/structured-outputs/supported-schemas + - False: + Input schema will not be validated and model output will not be + validated. + - None: + ``strict`` argument will not be passed to the model. + + If ``method`` is "json_schema" or "function_calling" defaults to True. + If ``method`` is "json_mode" defaults to None. Can only be non-null + if ``method`` is "function_calling" or "json_schema". + + kwargs: Additional keyword args aren't supported. + + Returns: + A Runnable that takes same inputs as a :class:`langchain_core.language_models.chat.BaseChatModel`. + + | If ``include_raw`` is False and ``schema`` is a Pydantic class, Runnable outputs an instance of ``schema`` (i.e., a Pydantic object). Otherwise, if ``include_raw`` is False then Runnable outputs a dict. + + | If ``include_raw`` is True, then Runnable outputs a dict with keys: + + - "raw": BaseMessage + - "parsed": None if there was a parsing error, otherwise the type depends on the ``schema`` as described above. + - "parsing_error": Optional[BaseException] + + .. versionchanged:: 0.1.20 + + Added support for TypedDict class ``schema``. + + .. versionchanged:: 0.1.21 + + Support for ``strict`` argument added. + Support for ``method`` = "json_schema" added. + + .. versionchanged:: 0.3.0 + + - ``method`` default changed from "function_calling" to "json_schema". + - ``strict`` defaults to True instead of False when ``method`` is + "function_calling". + + .. dropdown:: Example: schema=Pydantic class, method="json_schema", include_raw=False, strict=True + + Note, OpenAI has a number of restrictions on what types of schemas can be + provided if ``strict`` = True. When using Pydantic, our model cannot + specify any Field metadata (like min/max constraints) and fields cannot + have default values. + + See all constraints here: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas + + .. code-block:: python + + from typing import Optional + + from langchain_openai import ChatOpenAI + from pydantic import BaseModel, Field + + + class AnswerWithJustification(BaseModel): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: Optional[str] = Field( + default=..., description="A justification for the answer." + ) + + + llm = ChatOpenAI(model="gpt-4o", temperature=0) + structured_llm = llm.with_structured_output(AnswerWithJustification) + + structured_llm.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + + # -> AnswerWithJustification( + # answer='They weigh the same', + # justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.' + # ) + + .. dropdown:: Example: schema=Pydantic class, method="json_schema", include_raw=True + + .. code-block:: python + + from langchain_openai import ChatOpenAI + from pydantic import BaseModel + + + class AnswerWithJustification(BaseModel): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: str + + + llm = ChatOpenAI(model="gpt-4o", temperature=0) + structured_llm = llm.with_structured_output( + AnswerWithJustification, include_raw=True + ) + + structured_llm.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + # -> { + # 'raw': AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_Ao02pnFYXD6GN1yzc0uXPsvF', 'function': {'arguments': '{"answer":"They weigh the same.","justification":"Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ."}', 'name': 'AnswerWithJustification'}, 'type': 'function'}]}), + # 'parsed': AnswerWithJustification(answer='They weigh the same.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.'), + # 'parsing_error': None + # } + + .. dropdown:: Example: schema=TypedDict class, method="json_schema", include_raw=False + + .. code-block:: python + + # IMPORTANT: If you are using Python <=3.8, you need to import Annotated + # from typing_extensions, not from typing. + from typing_extensions import Annotated, TypedDict + + from langchain_openai import ChatOpenAI + + + class AnswerWithJustification(TypedDict): + '''An answer to the user question along with justification for the answer.''' + + answer: str + justification: Annotated[ + Optional[str], None, "A justification for the answer." + ] + + + llm = ChatOpenAI(model="gpt-4o", temperature=0) + structured_llm = llm.with_structured_output(AnswerWithJustification) + + structured_llm.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + # -> { + # 'answer': 'They weigh the same', + # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.' + # } + + .. dropdown:: Example: schema=OpenAI function schema, method="json_schema", include_raw=False + + .. code-block:: python + + from langchain_openai import ChatOpenAI + + oai_schema = { + 'name': 'AnswerWithJustification', + 'description': 'An answer to the user question along with justification for the answer.', + 'parameters': { + 'type': 'object', + 'properties': { + 'answer': {'type': 'string'}, + 'justification': {'description': 'A justification for the answer.', 'type': 'string'} + }, + 'required': ['answer'] + } + } + + llm = ChatOpenAI(model="gpt-4o", temperature=0) + structured_llm = llm.with_structured_output(oai_schema) + + structured_llm.invoke( + "What weighs more a pound of bricks or a pound of feathers" + ) + # -> { + # 'answer': 'They weigh the same', + # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.' + # } + + .. dropdown:: Example: schema=Pydantic class, method="json_mode", include_raw=True + + .. code-block:: + + from langchain_openai import ChatOpenAI + from pydantic import BaseModel + + class AnswerWithJustification(BaseModel): + answer: str + justification: str + + llm = ChatOpenAI(model="gpt-4o", temperature=0) + structured_llm = llm.with_structured_output( + AnswerWithJustification, + method="json_mode", + include_raw=True + ) + + structured_llm.invoke( + "Answer the following question. " + "Make sure to return a JSON blob with keys 'answer' and 'justification'.\\n\\n" + "What's heavier a pound of bricks or a pound of feathers?" + ) + # -> { + # 'raw': AIMessage(content='{\\n "answer": "They are both the same weight.",\\n "justification": "Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight." \\n}'), + # 'parsed': AnswerWithJustification(answer='They are both the same weight.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight.'), + # 'parsing_error': None + # } + + .. dropdown:: Example: schema=None, method="json_mode", include_raw=True + + .. code-block:: + + structured_llm = llm.with_structured_output(method="json_mode", include_raw=True) + + structured_llm.invoke( + "Answer the following question. " + "Make sure to return a JSON blob with keys 'answer' and 'justification'.\\n\\n" + "What's heavier a pound of bricks or a pound of feathers?" + ) + # -> { + # 'raw': AIMessage(content='{\\n "answer": "They are both the same weight.",\\n "justification": "Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight." \\n}'), + # 'parsed': { + # 'answer': 'They are both the same weight.', + # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight.' + # }, + # 'parsing_error': None + # } + """ # noqa: E501 + if method in ("json_schema", "function_calling") and strict is None: + strict = False + return super().with_structured_output( + schema, method=method, include_raw=include_raw, strict=strict, **kwargs + ) + def _is_pydantic_class(obj: Any) -> bool: return isinstance(obj, type) and is_basemodel_subclass(obj) diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_azure_standard.py b/libs/partners/openai/tests/integration_tests/chat_models/test_azure_standard.py index acf44a5ac0b3a..f069750934729 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_azure_standard.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_azure_standard.py @@ -55,6 +55,10 @@ def chat_model_params(self) -> dict: "azure_endpoint": OPENAI_API_BASE, } + @property + def structured_output_kwargs(self) -> dict: + return {"method": "function_calling"} + @pytest.mark.xfail(reason="Not yet supported.") def test_usage_metadata_streaming(self, model: BaseChatModel) -> None: super().test_usage_metadata_streaming(model) diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_base.py b/libs/partners/openai/tests/integration_tests/chat_models/test_base.py index 506799aef4b59..e8a2bd52d0796 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_base.py @@ -637,11 +637,18 @@ class MyModel(BaseModel): name: str age: int - llm = ChatOpenAI().with_structured_output(MyModel) - result = llm.invoke("I'm a 27 year old named Erick") - assert isinstance(result, MyModel) - assert result.name == "Erick" - assert result.age == 27 + for model in ["gpt-4o-mini", "o1"]: + llm = ChatOpenAI(model=model).with_structured_output(MyModel) + result = llm.invoke("I'm a 27 year old named Erick") + assert isinstance(result, MyModel) + assert result.name == "Erick" + assert result.age == 27 + + # Test legacy models raise error + llm = ChatOpenAI(model="gpt-4").with_structured_output(MyModel) + with pytest.raises(ValueError) as exception_info: + _ = llm.invoke("I'm a 27 year old named Erick") + assert "with_structured_output" in str(exception_info.value) def test_openai_proxy() -> None: @@ -820,20 +827,18 @@ class magic_function(BaseModel): @pytest.mark.parametrize( - ("model", "method", "strict"), - [("gpt-4o", "function_calling", True), ("gpt-4o-2024-08-06", "json_schema", None)], + ("model", "method"), + [("gpt-4o", "function_calling"), ("gpt-4o-2024-08-06", "json_schema")], ) def test_structured_output_strict( - model: str, - method: Literal["function_calling", "json_schema"], - strict: Optional[bool], + model: str, method: Literal["function_calling", "json_schema"] ) -> None: """Test to verify structured output with strict=True.""" from pydantic import BaseModel as BaseModelProper from pydantic import Field as FieldProper - llm = ChatOpenAI(model=model, temperature=0) + llm = ChatOpenAI(model=model) class Joke(BaseModelProper): """Joke to tell user.""" @@ -842,10 +847,7 @@ class Joke(BaseModelProper): punchline: str = FieldProper(description="answer to resolve the joke") # Pydantic class - # Type ignoring since the interface only officially supports pydantic 1 - # or pydantic.v1.BaseModel but not pydantic.BaseModel from pydantic 2. - # We'll need to do a pass updating the type signatures. - chat = llm.with_structured_output(Joke, method=method, strict=strict) + chat = llm.with_structured_output(Joke, method=method, strict=True) result = chat.invoke("Tell me a joke about cats.") assert isinstance(result, Joke) @@ -854,7 +856,7 @@ class Joke(BaseModelProper): # Schema chat = llm.with_structured_output( - Joke.model_json_schema(), method=method, strict=strict + Joke.model_json_schema(), method=method, strict=True ) result = chat.invoke("Tell me a joke about cats.") assert isinstance(result, dict) @@ -875,14 +877,14 @@ class InvalidJoke(BaseModelProper): default="foo", description="answer to resolve the joke" ) - chat = llm.with_structured_output(InvalidJoke, method=method, strict=strict) + chat = llm.with_structured_output(InvalidJoke, method=method, strict=True) with pytest.raises(openai.BadRequestError): chat.invoke("Tell me a joke about cats.") with pytest.raises(openai.BadRequestError): next(chat.stream("Tell me a joke about cats.")) chat = llm.with_structured_output( - InvalidJoke.model_json_schema(), method=method, strict=strict + InvalidJoke.model_json_schema(), method=method, strict=True ) with pytest.raises(openai.BadRequestError): chat.invoke("Tell me a joke about cats.") @@ -890,11 +892,9 @@ class InvalidJoke(BaseModelProper): next(chat.stream("Tell me a joke about cats.")) -@pytest.mark.parametrize( - ("model", "method", "strict"), [("gpt-4o-2024-08-06", "json_schema", None)] -) +@pytest.mark.parametrize(("model", "method"), [("gpt-4o-2024-08-06", "json_schema")]) def test_nested_structured_output_strict( - model: str, method: Literal["json_schema"], strict: Optional[bool] + model: str, method: Literal["json_schema"] ) -> None: """Test to verify structured output with strict=True for nested object.""" @@ -914,7 +914,7 @@ class JokeWithEvaluation(TypedDict): self_evaluation: SelfEvaluation # Schema - chat = llm.with_structured_output(JokeWithEvaluation, method=method, strict=strict) + chat = llm.with_structured_output(JokeWithEvaluation, method=method, strict=True) result = chat.invoke("Tell me a joke about cats.") assert isinstance(result, dict) assert set(result.keys()) == {"setup", "punchline", "self_evaluation"} diff --git a/libs/standard-tests/langchain_tests/integration_tests/chat_models.py b/libs/standard-tests/langchain_tests/integration_tests/chat_models.py index f569135004497..69116b6cfb7a9 100644 --- a/libs/standard-tests/langchain_tests/integration_tests/chat_models.py +++ b/libs/standard-tests/langchain_tests/integration_tests/chat_models.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, Field from pydantic.v1 import BaseModel as BaseModelV1 from pydantic.v1 import Field as FieldV1 +from typing_extensions import Annotated, TypedDict from langchain_tests.unit_tests.chat_models import ( ChatModelTests, @@ -191,6 +192,19 @@ def tool_choice_value(self) -> Optional[str]: def has_structured_output(self) -> bool: return True + .. dropdown:: structured_output_kwargs + + Dict property that can be used to specify additional kwargs for + ``with_structured_output``. Useful for testing different models. + + Example: + + .. code-block:: python + + @property + def structured_output_kwargs(self) -> dict: + return {"method": "function_calling"} + .. dropdown:: supports_json_mode Boolean property indicating whether the chat model supports JSON mode in @@ -1128,10 +1142,7 @@ def has_tool_calling(self) -> bool: Joke = _get_joke_class() # Pydantic class - # Type ignoring since the interface only officially supports pydantic 1 - # or pydantic.v1.BaseModel but not pydantic.BaseModel from pydantic 2. - # We'll need to do a pass updating the type signatures. - chat = model.with_structured_output(Joke) # type: ignore[arg-type] + chat = model.with_structured_output(Joke, **self.structured_output_kwargs) result = chat.invoke("Tell me a joke about cats.") assert isinstance(result, Joke) @@ -1139,7 +1150,9 @@ def has_tool_calling(self) -> bool: assert isinstance(chunk, Joke) # Schema - chat = model.with_structured_output(Joke.model_json_schema()) + chat = model.with_structured_output( + Joke.model_json_schema(), **self.structured_output_kwargs + ) result = chat.invoke("Tell me a joke about cats.") assert isinstance(result, dict) assert set(result.keys()) == {"setup", "punchline"} @@ -1182,10 +1195,7 @@ def has_tool_calling(self) -> bool: Joke = _get_joke_class() # Pydantic class - # Type ignoring since the interface only officially supports pydantic 1 - # or pydantic.v1.BaseModel but not pydantic.BaseModel from pydantic 2. - # We'll need to do a pass updating the type signatures. - chat = model.with_structured_output(Joke) # type: ignore[arg-type] + chat = model.with_structured_output(Joke, **self.structured_output_kwargs) result = await chat.ainvoke("Tell me a joke about cats.") assert isinstance(result, Joke) @@ -1193,7 +1203,9 @@ def has_tool_calling(self) -> bool: assert isinstance(chunk, Joke) # Schema - chat = model.with_structured_output(Joke.model_json_schema()) + chat = model.with_structured_output( + Joke.model_json_schema(), **self.structured_output_kwargs + ) result = await chat.ainvoke("Tell me a joke about cats.") assert isinstance(result, dict) assert set(result.keys()) == {"setup", "punchline"} @@ -1244,7 +1256,7 @@ class Joke(BaseModelV1): # Uses langchain_core.pydantic_v1.BaseModel punchline: str = FieldV1(description="answer to resolve the joke") # Pydantic class - chat = model.with_structured_output(Joke) + chat = model.with_structured_output(Joke, **self.structured_output_kwargs) result = chat.invoke("Tell me a joke about cats.") assert isinstance(result, Joke) @@ -1252,7 +1264,9 @@ class Joke(BaseModelV1): # Uses langchain_core.pydantic_v1.BaseModel assert isinstance(chunk, Joke) # Schema - chat = model.with_structured_output(Joke.schema()) + chat = model.with_structured_output( + Joke.schema(), **self.structured_output_kwargs + ) result = chat.invoke("Tell me a joke about cats.") assert isinstance(result, dict) assert set(result.keys()) == {"setup", "punchline"} @@ -1293,6 +1307,7 @@ def has_tool_calling(self) -> bool: if not self.has_tool_calling: pytest.skip("Test requires tool calling.") + # Pydantic class Joke(BaseModel): """Joke to tell user.""" @@ -1301,7 +1316,7 @@ class Joke(BaseModel): default=None, description="answer to resolve the joke" ) - chat = model.with_structured_output(Joke) # type: ignore[arg-type] + chat = model.with_structured_output(Joke, **self.structured_output_kwargs) setup_result = chat.invoke( "Give me the setup to a joke about cats, no punchline." ) @@ -1310,6 +1325,24 @@ class Joke(BaseModel): joke_result = chat.invoke("Give me a joke about cats, include the punchline.") assert isinstance(joke_result, Joke) + # Schema + chat = model.with_structured_output( + Joke.model_json_schema(), **self.structured_output_kwargs + ) + result = chat.invoke("Tell me a joke about cats.") + assert isinstance(result, dict) + + # TypedDict + class JokeDict(TypedDict): + """Joke to tell user.""" + + setup: Annotated[str, ..., "question to set up a joke"] + punchline: Annotated[Optional[str], None, "answer to resolve the joke"] + + chat = model.with_structured_output(JokeDict, **self.structured_output_kwargs) + result = chat.invoke("Tell me a joke about cats.") + assert isinstance(result, dict) + def test_json_mode(self, model: BaseChatModel) -> None: """Test structured output via `JSON mode. `_ diff --git a/libs/standard-tests/langchain_tests/unit_tests/chat_models.py b/libs/standard-tests/langchain_tests/unit_tests/chat_models.py index 766367f7359c2..84a51385b6d05 100644 --- a/libs/standard-tests/langchain_tests/unit_tests/chat_models.py +++ b/libs/standard-tests/langchain_tests/unit_tests/chat_models.py @@ -132,6 +132,11 @@ def has_structured_output(self) -> bool: is not BaseChatModel.with_structured_output ) + @property + def structured_output_kwargs(self) -> dict: + """If specified, additional kwargs for with_structured_output.""" + return {} + @property def supports_json_mode(self) -> bool: """(bool) whether the chat model supports JSON mode.""" @@ -299,6 +304,19 @@ def tool_choice_value(self) -> Optional[str]: def has_structured_output(self) -> bool: return True + .. dropdown:: structured_output_kwargs + + Dict property that can be used to specify additional kwargs for + ``with_structured_output``. Useful for testing different models. + + Example: + + .. code-block:: python + + @property + def structured_output_kwargs(self) -> dict: + return {"method": "function_calling"} + .. dropdown:: supports_json_mode Boolean property indicating whether the chat model supports JSON mode in From 93e2d3977f6a258a8c781137fcd43e1f9793e794 Mon Sep 17 00:00:00 2001 From: Chester Curme Date: Thu, 9 Jan 2025 14:42:11 -0500 Subject: [PATCH 3/5] raise informative error message when streaming --- .../langchain_openai/chat_models/base.py | 86 +++++++++++-------- .../chat_models/test_base.py | 27 ++++-- 2 files changed, 67 insertions(+), 46 deletions(-) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index cdbcb76871fa8..df3dfd013bcda 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -701,26 +701,31 @@ def _stream( else: response = self.client.create(**payload) context_manager = response - with context_manager as response: - is_first_chunk = True - for chunk in response: - if not isinstance(chunk, dict): - chunk = chunk.model_dump() - generation_chunk = _convert_chunk_to_generation_chunk( - chunk, - default_chunk_class, - base_generation_info if is_first_chunk else {}, - ) - if generation_chunk is None: - continue - default_chunk_class = generation_chunk.message.__class__ - logprobs = (generation_chunk.generation_info or {}).get("logprobs") - if run_manager: - run_manager.on_llm_new_token( - generation_chunk.text, chunk=generation_chunk, logprobs=logprobs + try: + with context_manager as response: + is_first_chunk = True + for chunk in response: + if not isinstance(chunk, dict): + chunk = chunk.model_dump() + generation_chunk = _convert_chunk_to_generation_chunk( + chunk, + default_chunk_class, + base_generation_info if is_first_chunk else {}, ) - is_first_chunk = False - yield generation_chunk + if generation_chunk is None: + continue + default_chunk_class = generation_chunk.message.__class__ + logprobs = (generation_chunk.generation_info or {}).get("logprobs") + if run_manager: + run_manager.on_llm_new_token( + generation_chunk.text, + chunk=generation_chunk, + logprobs=logprobs, + ) + is_first_chunk = False + yield generation_chunk + except openai.BadRequestError as e: + _handle_openai_bad_request(e) if hasattr(response, "get_final_completion") and "response_format" in payload: final_completion = response.get_final_completion() generation_chunk = self._get_generation_chunk_from_completion( @@ -864,26 +869,31 @@ async def _astream( else: response = await self.async_client.create(**payload) context_manager = response - async with context_manager as response: - is_first_chunk = True - async for chunk in response: - if not isinstance(chunk, dict): - chunk = chunk.model_dump() - generation_chunk = _convert_chunk_to_generation_chunk( - chunk, - default_chunk_class, - base_generation_info if is_first_chunk else {}, - ) - if generation_chunk is None: - continue - default_chunk_class = generation_chunk.message.__class__ - logprobs = (generation_chunk.generation_info or {}).get("logprobs") - if run_manager: - await run_manager.on_llm_new_token( - generation_chunk.text, chunk=generation_chunk, logprobs=logprobs + try: + async with context_manager as response: + is_first_chunk = True + async for chunk in response: + if not isinstance(chunk, dict): + chunk = chunk.model_dump() + generation_chunk = _convert_chunk_to_generation_chunk( + chunk, + default_chunk_class, + base_generation_info if is_first_chunk else {}, ) - is_first_chunk = False - yield generation_chunk + if generation_chunk is None: + continue + default_chunk_class = generation_chunk.message.__class__ + logprobs = (generation_chunk.generation_info or {}).get("logprobs") + if run_manager: + await run_manager.on_llm_new_token( + generation_chunk.text, + chunk=generation_chunk, + logprobs=logprobs, + ) + is_first_chunk = False + yield generation_chunk + except openai.BadRequestError as e: + _handle_openai_bad_request(e) if hasattr(response, "get_final_completion") and "response_format" in payload: final_completion = await response.get_final_completion() generation_chunk = self._get_generation_chunk_from_completion( diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_base.py b/libs/partners/openai/tests/integration_tests/chat_models/test_base.py index e8a2bd52d0796..314f6cb79abee 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_base.py @@ -630,26 +630,37 @@ def test_bind_tools_tool_choice() -> None: assert not msg.tool_calls -def test_openai_structured_output() -> None: +@pytest.mark.parametrize("model", ["gpt-4o-mini", "o1"]) +def test_openai_structured_output(model: str) -> None: class MyModel(BaseModel): """A Person""" name: str age: int - for model in ["gpt-4o-mini", "o1"]: - llm = ChatOpenAI(model=model).with_structured_output(MyModel) - result = llm.invoke("I'm a 27 year old named Erick") - assert isinstance(result, MyModel) - assert result.name == "Erick" - assert result.age == 27 + llm = ChatOpenAI(model=model).with_structured_output(MyModel) + result = llm.invoke("I'm a 27 year old named Erick") + assert isinstance(result, MyModel) + assert result.name == "Erick" + assert result.age == 27 + + +def test_structured_output_errors_with_legacy_models() -> None: + class MyModel(BaseModel): + """A Person""" + + name: str + age: int - # Test legacy models raise error llm = ChatOpenAI(model="gpt-4").with_structured_output(MyModel) with pytest.raises(ValueError) as exception_info: _ = llm.invoke("I'm a 27 year old named Erick") assert "with_structured_output" in str(exception_info.value) + with pytest.raises(ValueError) as exception_info: + _ = list(llm.stream("I'm a 27 year old named Erick")) + assert "with_structured_output" in str(exception_info.value) + def test_openai_proxy() -> None: """Test ChatOpenAI with proxy.""" From 21b87a9df6477a34eebb913913891c33e8243a23 Mon Sep 17 00:00:00 2001 From: Chester Curme Date: Thu, 9 Jan 2025 14:51:31 -0500 Subject: [PATCH 4/5] fix docstrings --- .../langchain_openai/chat_models/base.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index df3dfd013bcda..d0df7b5724c38 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -1266,13 +1266,13 @@ def with_structured_output( method: The method for steering model generation, one of: + - "function_calling": + Uses OpenAI's tool-calling (formerly called function calling) + API: https://platform.openai.com/docs/guides/function-calling - "json_schema": Uses OpenAI's Structured Output API: https://platform.openai.com/docs/guides/structured-outputs Supported for "gpt-4o-mini", "gpt-4o-2024-08-06", "o1", and later models. - - "function_calling": - Uses OpenAI's tool-calling (formerly called function calling) - API: https://platform.openai.com/docs/guides/function-calling - "json_mode": Uses OpenAI's JSON mode. Note that if using JSON mode then you must include instructions for formatting the output into the @@ -1304,10 +1304,6 @@ def with_structured_output( - None: ``strict`` argument will not be passed to the model. - Defaults to False if ``method`` is ``"json_schema"`` or - ``"function_calling"``. Can only be non-null if ``method`` is - ``"json_schema"`` or ``"function_calling"``. - kwargs: Additional keyword args aren't supported. Returns: @@ -2041,7 +2037,7 @@ def with_structured_output( - "json_schema": Uses OpenAI's Structured Output API: https://platform.openai.com/docs/guides/structured-outputs - Supported for "gpt-4o-mini", "gpt-4o-2024-08-06", and later + Supported for "gpt-4o-mini", "gpt-4o-2024-08-06", "o1", and later models. - "function_calling": Uses OpenAI's tool-calling (formerly called function calling) @@ -2077,9 +2073,9 @@ def with_structured_output( - None: ``strict`` argument will not be passed to the model. - If ``method`` is "json_schema" or "function_calling" defaults to True. - If ``method`` is "json_mode" defaults to None. Can only be non-null - if ``method`` is "function_calling" or "json_schema". + Defaults to False if ``method`` is ``"json_schema"`` or + ``"function_calling"``. Can only be non-null if ``method`` is + ``"json_schema"`` or ``"function_calling"``. kwargs: Additional keyword args aren't supported. From 28012740f0dd5dd9c1170b47e8ee27d91f3366b4 Mon Sep 17 00:00:00 2001 From: Chester Curme Date: Thu, 9 Jan 2025 14:52:44 -0500 Subject: [PATCH 5/5] bump core and increment version --- libs/partners/openai/poetry.lock | 6 +++--- libs/partners/openai/pyproject.toml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/partners/openai/poetry.lock b/libs/partners/openai/poetry.lock index d7610271ad57f..85b65dbf801a6 100644 --- a/libs/partners/openai/poetry.lock +++ b/libs/partners/openai/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -496,7 +496,7 @@ files = [ [[package]] name = "langchain-core" -version = "0.3.27" +version = "0.3.29" description = "Building applications with LLMs through composability" optional = false python-versions = ">=3.9,<4.0" @@ -1647,4 +1647,4 @@ watchmedo = ["PyYAML (>=3.10)"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "71de53990a6cfb9cd6a25249b40eeef52e089840a9a06b54ac556fe7fa60504c" +content-hash = "0bc715ae349e68aa13cce7541210fb9596a6a66a9a5479fdc5c891c79ca11688" diff --git a/libs/partners/openai/pyproject.toml b/libs/partners/openai/pyproject.toml index 693e34eda37d1..77ef40180e498 100644 --- a/libs/partners/openai/pyproject.toml +++ b/libs/partners/openai/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "langchain-openai" -version = "0.2.14" +version = "0.3.0" description = "An integration package connecting OpenAI and LangChain" authors = [] readme = "README.md" @@ -23,7 +23,7 @@ ignore_missing_imports = true [tool.poetry.dependencies] python = ">=3.9,<4.0" -langchain-core = "^0.3.27" +langchain-core = "^0.3.29" openai = "^1.58.1" tiktoken = ">=0.7,<1"