From 011775fda2f717c0298c15312f2244ef71bacad7 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Fri, 4 Sep 2026 12:22:39 +0300 Subject: [PATCH] fix(langchain): stop dropping temperature on gpt-5.x models langchain-openai strips temperature for every gpt-5* model unless reasoning effort is explicitly the string "none". Unset effort is None, not "none", so a configured temperature never reached the dotted variants, which default to effort none and do accept it. Nothing errored or warned. Gpt5TemperatureMixin overrides both strip sites on UiPathChatOpenAI and UiPathAzureChatOpenAI and defers to langchain everywhere it is already right, so base gpt-5, pro variants, reasoning-enabled models and models flagged shouldSkipTemperature by discovery are unaffected. Two canary tests assert the upstream bug is still present, so this gets deleted once the upstream fix lands. Verified end to end through the agents runtime against gpt-5.4. --- packages/uipath_langchain_client/CHANGELOG.md | 5 ++ .../uipath_langchain_client/__version__.py | 2 +- .../clients/openai/chat_models.py | 7 +- .../clients/openai/gpt5_temperature.py | 83 ++++++++++++++++++ tests/langchain/test_gpt5_temperature.py | 87 +++++++++++++++++++ 5 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/gpt5_temperature.py create mode 100644 tests/langchain/test_gpt5_temperature.py diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index adb9414..3224bdd 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.18.4] - 2026-09-04 + +### Fixed +- `temperature` now reaches the dotted gpt-5 variants (`gpt-5.2`, `gpt-5.4`) instead of being silently dropped. langchain-openai strips it for every `gpt-5*` model unless reasoning effort is explicitly `"none"`, but the dotted variants default to effort `none` and do accept it. `Gpt5TemperatureMixin` on `UiPathChatOpenAI` and `UiPathAzureChatOpenAI` overrides both strip sites and defers to langchain elsewhere, so base `gpt-5`, `pro` variants, reasoning-enabled models, and `shouldSkipTemperature` models are unaffected. Remove once langchain-ai/langchain#35424 ships. + ## [1.18.3] - 2026-09-02 ### Added diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index f570ce1..b91c30e 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LangChain Client" __description__ = "A Python client for interacting with UiPath's LLM services via LangChain." -__version__ = "1.18.3" +__version__ = "1.18.4" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py index 6e203b0..023c9e1 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py @@ -5,6 +5,7 @@ from pydantic import Field, SecretStr, model_validator from uipath_langchain_client.base_client import UiPathBaseChatModel +from uipath_langchain_client.clients.openai.gpt5_temperature import Gpt5TemperatureMixin from uipath_langchain_client.clients.openai.tool_call_extras import ( OpenAIToolCallExtrasMixin, ) @@ -27,7 +28,9 @@ ) from e -class UiPathChatOpenAI(UiPathBaseChatModel, OpenAIToolCallExtrasMixin, ChatOpenAI): # type: ignore[override] +class UiPathChatOpenAI( # type: ignore[override] + UiPathBaseChatModel, OpenAIToolCallExtrasMixin, Gpt5TemperatureMixin, ChatOpenAI +): api_config: UiPathAPIConfig = UiPathAPIConfig( api_type=ApiType.COMPLETIONS, routing_mode=RoutingMode.PASSTHROUGH, @@ -83,7 +86,7 @@ async def on_request_async(request: Request) -> None: return self -class UiPathAzureChatOpenAI(UiPathBaseChatModel, AzureChatOpenAI): # type: ignore[override] +class UiPathAzureChatOpenAI(UiPathBaseChatModel, Gpt5TemperatureMixin, AzureChatOpenAI): # type: ignore[override] api_config: UiPathAPIConfig = UiPathAPIConfig( api_type=ApiType.COMPLETIONS, routing_mode=RoutingMode.PASSTHROUGH, diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/gpt5_temperature.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/gpt5_temperature.py new file mode 100644 index 0000000..efb0bc0 --- /dev/null +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/gpt5_temperature.py @@ -0,0 +1,83 @@ +"""Keep ``temperature`` on gpt-5 variants that default to no reasoning. + +langchain-openai strips ``temperature`` for every ``gpt-5*`` model unless reasoning +effort is explicitly the string ``"none"``, at two sites in +``langchain_openai.chat_models.base``: ``validate_temperature`` and +``_construct_responses_api_payload``. Unset effort is ``None``, not ``"none"``, so +the value is dropped by default. That is right for base ``gpt-5`` and the ``pro`` +variants (default effort medium) but wrong for the dotted ones (``gpt-5.2``, +``gpt-5.4``), which default to effort ``none`` and do accept it. + +Delete once langchain-ai/langchain#35424 ships. Upstream: langchain-ai/langchain#35423. +""" + +import re +from collections.abc import Mapping +from typing import Any, cast + +from langchain_core.language_models import LanguageModelInput +from pydantic import model_validator + +_DOTTED_GPT5 = re.compile(r"gpt-5\.\d+") + + +def gpt5_keeps_temperature( + model: str | None, + *, + reasoning_effort: str | None = None, + reasoning: Mapping[str, Any] | None = None, +) -> bool: + """Whether this model accepts ``temperature`` as currently configured. + + True only for dotted gpt-5 variants with effort unset or ``"none"``. False + everywhere else, so the caller defers to langchain. + """ + name = (model or "").lower() + if "chat" in name or "pro" in name: + return False + if not _DOTTED_GPT5.match(name): + return False + effort = reasoning_effort or (reasoning or {}).get("effort") + return effort is None or effort == "none" + + +class Gpt5TemperatureMixin: + """Restore ``temperature`` at both sites langchain-openai strips it. + + Mix in ahead of the vendor chat class so the overrides win on the MRO. + """ + + @model_validator(mode="before") + @classmethod + def validate_temperature(cls, values: dict[str, Any]) -> Any: + """Skip langchain's strip when the model does support ``temperature``.""" + if gpt5_keeps_temperature( + values.get("model_name") or values.get("model"), + reasoning_effort=values.get("reasoning_effort"), + reasoning=values.get("reasoning"), + ): + return values + return cast(Any, super()).validate_temperature(values) + + def _get_request_payload( + self, + input_: LanguageModelInput, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Put ``temperature`` back after the Responses payload builder drops it.""" + payload = cast( + dict[str, Any], + cast(Any, super())._get_request_payload(input_, stop=stop, **kwargs), + ) + if "temperature" in payload: + return payload + requested = kwargs.get("temperature", getattr(self, "temperature", None)) + if requested is None: + return payload + if "temperature" in (getattr(self, "disabled_params", None) or {}): + return payload + if gpt5_keeps_temperature(payload.get("model"), reasoning=payload.get("reasoning")): + payload["temperature"] = requested + return payload diff --git a/tests/langchain/test_gpt5_temperature.py b/tests/langchain/test_gpt5_temperature.py new file mode 100644 index 0000000..c15367b --- /dev/null +++ b/tests/langchain/test_gpt5_temperature.py @@ -0,0 +1,87 @@ +"""Temperature survives on gpt-5 variants that default to no reasoning. + +Covers both sites langchain-openai strips it, and pins the cases where stripping +is correct. Upstream: langchain-ai/langchain#35423. +""" + +from typing import Any + +import pytest +from langchain_core.messages import HumanMessage +from langchain_openai.chat_models import ChatOpenAI +from pydantic import SecretStr +from uipath_langchain_client.clients.openai.chat_models import ( + UiPathAzureChatOpenAI, + UiPathChatOpenAI, +) +from uipath_langchain_client.settings import ApiFlavor + +from uipath.llm_client.settings import UiPathBaseSettings + +MESSAGES = [HumanMessage(content="hi")] + + +def _build(client_settings: UiPathBaseSettings, **kwargs: Any) -> Any: + chat_class = kwargs.pop("chat_class", UiPathChatOpenAI) + return chat_class( + model=kwargs.pop("model", "gpt-5.4"), + client_settings=client_settings, + model_details=kwargs.pop("model_details", {}), + api_flavor=ApiFlavor.RESPONSES, + **kwargs, + ) + + +def _payload(chat: Any) -> dict[str, Any]: + return chat._get_request_payload(MESSAGES) + + +@pytest.mark.parametrize("chat_class", [UiPathChatOpenAI, UiPathAzureChatOpenAI]) +def test_dotted_gpt5_keeps_temperature( + chat_class: type, client_settings: UiPathBaseSettings +) -> None: + chat = _build(client_settings, chat_class=chat_class, temperature=0.64) + assert chat.temperature == 0.64 + assert _payload(chat)["temperature"] == 0.64 + + +@pytest.mark.parametrize( + "overrides", + [ + {"reasoning_effort": "low"}, + {"reasoning": {"effort": "low"}}, + {"model": "gpt-5"}, + {"model": "gpt-5.4-pro"}, + ], + ids=["effort", "reasoning-dict", "base-gpt-5", "pro"], +) +def test_dropped_where_temperature_is_unsupported( + overrides: dict[str, Any], client_settings: UiPathBaseSettings +) -> None: + chat = _build(client_settings, temperature=0.64, **overrides) + assert chat.temperature is None + assert "temperature" not in _payload(chat) + + +def test_discovery_skip_flag_blocks_the_restore( + client_settings: UiPathBaseSettings, +) -> None: + chat = _build(client_settings, model_details={"shouldSkipTemperature": True}) + assert "temperature" not in chat._get_request_payload(MESSAGES, temperature=0.5) + + +def test_langchain_handling_still_delegated(client_settings: UiPathBaseSettings) -> None: + assert _build(client_settings, model="o1").temperature == 1 + assert _build(client_settings, model="gpt-5-chat", temperature=0.64).temperature == 0.64 + + +def test_upstream_bug_still_present() -> None: + """Fails when langchain-openai fixes #35423; delete the shim and this file then.""" + plain = ChatOpenAI( + model="gpt-5.4", + api_key=SecretStr("x"), + temperature=0.64, + use_responses_api=True, + ) + assert plain.temperature is None + assert "temperature" not in plain._get_request_payload(MESSAGES)