diff --git a/libs/core/langchain_core/language_models/model_profile.py b/libs/core/langchain_core/language_models/model_profile.py index e5eedef071..bbfdc87a59 100644 --- a/libs/core/langchain_core/language_models/model_profile.py +++ b/libs/core/langchain_core/language_models/model_profile.py @@ -91,6 +91,22 @@ class ModelProfile(TypedDict, total=False): reasoning_output: bool """Whether the model supports [reasoning / chain-of-thought](https://docs.langchain.com/oss/python/langchain/models#reasoning).""" + reasoning_effort_levels: list[str] + """Supported reasoning-effort levels (e.g. `['low', 'medium', 'high']`). + + Absent or empty if the model does not support a configurable reasoning + effort. Only meaningful when `reasoning_output` is `True`. + """ + + reasoning_effort_default: str + """The provider's documented default reasoning-effort level, if known. + + Absent when no default is documented. Only meaningful when + `reasoning_effort_levels` is non-empty; not necessarily a member of + `reasoning_effort_levels` itself (a model may default to unconfigurable + behavior distinct from any explicit level). + """ + text_outputs: bool """Whether text outputs are supported.""" diff --git a/libs/langchain/tests/unit_tests/chat_models/test_base.py b/libs/langchain/tests/unit_tests/chat_models/test_base.py index c5d6499d93..52a8d4dbca 100644 --- a/libs/langchain/tests/unit_tests/chat_models/test_base.py +++ b/libs/langchain/tests/unit_tests/chat_models/test_base.py @@ -262,7 +262,7 @@ def test_configurable_with_default() -> None: "max_tokens": 64000, "temperature": None, "thinking": None, - "effort": None, + "reasoning_effort": None, "top_k": None, "top_p": None, "default_request_timeout": None, diff --git a/libs/langchain_v1/tests/unit_tests/chat_models/test_chat_models.py b/libs/langchain_v1/tests/unit_tests/chat_models/test_chat_models.py index 0282c3e307..0edeba972f 100644 --- a/libs/langchain_v1/tests/unit_tests/chat_models/test_chat_models.py +++ b/libs/langchain_v1/tests/unit_tests/chat_models/test_chat_models.py @@ -304,7 +304,7 @@ def test_configurable_with_default() -> None: "bound": { "name": None, "disable_streaming": False, - "effort": None, + "reasoning_effort": None, "model": "claude-sonnet-4-5-20250929", "mcp_servers": None, "max_tokens": 64000, diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index c926d80eab..7830b450f0 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -806,6 +806,21 @@ def _is_code_execution_related_block( return False +def _reasoning_effort_levels(profile: object) -> tuple[str, ...]: + """Return the reasoning-effort levels declared in a model's profile, if any. + + Defensive against a missing/malformed profile: an absent `profile`, a + non-mapping value, or a missing/non-list `reasoning_effort_levels` value is + treated as "no levels declared" rather than raising. + """ + if not isinstance(profile, Mapping): + return () + levels = profile.get("reasoning_effort_levels") + if not isinstance(levels, (list, tuple)): + return () + return tuple(levels) + + def _is_direct_anthropic_llm_type(llm_type: object) -> bool: """Return whether an `_llm_type` reaches Claude via the direct Anthropic API. @@ -1047,18 +1062,29 @@ class ChatAnthropic(BaseChatModel): [extended output](https://platform.claude.com/docs/en/api/go/beta/messages/create). """ - effort: Literal["max", "xhigh", "high", "medium", "low"] | None = None - """Convenience shorthand for `output_config.effort`. + reasoning_effort: Literal["max", "xhigh", "high", "medium", "low"] | None = Field( + default=None, + alias="effort", + ) + """Reasoning effort. - When set, this value takes precedence over any `effort` key inside - `output_config`. + Configures `output_config.effort`. If `thinking` isn't set explicitly, + defaults it to `{"type": "adaptive", "display": "summarized"}`. Can also + be passed at call time (for example, + `model.invoke(..., reasoning_effort="high")`). - Example: `effort="medium"` + !!! note "`effort` alias" + + `effort` is also accepted as an alias for this field, at both + construction and call time. If both `effort` and `reasoning_effort` are + set, `effort` wins (Pydantic's alias-resolution precedence). !!! note - Setting `effort` to `'high'` produces exactly the same behavior as omitting the - parameter altogether. + Setting `reasoning_effort` to `'high'` produces exactly the same behavior + as omitting the parameter altogether. + + Example: `reasoning_effort="medium"` """ mcp_servers: list[dict[str, Any]] | None = None @@ -1089,6 +1115,11 @@ class ChatAnthropic(BaseChatModel): docs for more information. """ + @property + def effort(self) -> Literal["max", "xhigh", "high", "medium", "low"] | None: + """Alias for `reasoning_effort`.""" + return self.reasoning_effort + @property def _llm_type(self) -> str: """Return type of chat model.""" @@ -1239,7 +1270,7 @@ class ChatAnthropic(BaseChatModel): input_: LanguageModelInput, *, stop: list[str] | None = None, - **kwargs: dict, + **kwargs: Any, ) -> dict: """Get the request payload for the Anthropic API.""" messages = self._convert_input(input_).to_messages() @@ -1312,26 +1343,56 @@ class ChatAnthropic(BaseChatModel): **self.model_kwargs, **kwargs, } + # Captured before `self.thinking` is applied below, so a call-time + # `thinking` kwarg counts as "explicitly set" too. + thinking_explicitly_set = "thinking" in payload or self.thinking is not None if self.thinking is not None: payload["thinking"] = self.thinking if self.inference_geo is not None: payload["inference_geo"] = self.inference_geo # Handle output_config and effort parameter - # Priority: self.effort > kwargs output_config > self.output_config + # Priority: kwarg `effort`/`reasoning_effort` > kwarg `output_config` + # > self.reasoning_effort > self.output_config output_config: dict[str, Any] = {} if self.output_config: output_config.update(self.output_config) + reasoning_effort_applied = False + if self.reasoning_effort: + output_config["effort"] = self.reasoning_effort + reasoning_effort_applied = True payload_oc = payload.get("output_config") if isinstance(payload_oc, dict): output_config.update(payload_oc) - if self.effort: - output_config["effort"] = self.effort + # Neither `reasoning_effort` nor its `effort` alias are Anthropic API + # fields. Pop them so they never leak through as top-level keys. + effort_kwarg = payload.pop("effort", None) + reasoning_effort_kwarg = payload.pop("reasoning_effort", None) + # `effort` wins if both are set at call time, matching the + # construction-time alias-resolution precedence (`Field(alias="effort")`). + reasoning_effort_override = ( + effort_kwarg if effort_kwarg is not None else reasoning_effort_kwarg + ) + if reasoning_effort_override: + output_config["effort"] = reasoning_effort_override + reasoning_effort_applied = True if output_config: payload["output_config"] = output_config + # Default adaptive thinking when `reasoning_effort` is set, unless the + # caller explicitly provided `thinking`. Gated on `xhigh` support: only + # Opus 4.7+/Sonnet 5 accept the adaptive+summarized `thinking` shape — + # sending it to an older model (e.g. Opus 4.5, 4.6) is rejected by the + # API with "adaptive thinking is not supported on this model". + if ( + reasoning_effort_applied + and not thinking_explicitly_set + and "xhigh" in _reasoning_effort_levels(self.profile) + ): + payload["thinking"] = {"type": "adaptive", "display": "summarized"} + if "response_format" in payload: # response_format present when using agents.create_agent's ProviderStrategy # --- diff --git a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py index 39d2bacf59..f87cabbe69 100644 --- a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py +++ b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py @@ -173,6 +173,12 @@ _PROFILES: dict[str, dict[str, Any]] = { "pdf_tool_message": True, "image_tool_message": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + ], + "reasoning_effort_default": "high", }, "claude-opus-4-5-20251101": { "name": "Claude Opus 4.5", @@ -199,6 +205,12 @@ _PROFILES: dict[str, dict[str, Any]] = { "pdf_tool_message": True, "image_tool_message": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + ], + "reasoning_effort_default": "high", }, "claude-opus-4-6": { "name": "Claude Opus 4.6", @@ -225,6 +237,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "pdf_tool_message": True, "image_tool_message": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + "max", + ], + "reasoning_effort_default": "high", }, "claude-opus-4-7": { "name": "Claude Opus 4.7", @@ -251,6 +270,14 @@ _PROFILES: dict[str, dict[str, Any]] = { "pdf_tool_message": True, "image_tool_message": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "high", }, "claude-opus-4-8": { "name": "Claude Opus 4.8", @@ -277,6 +304,14 @@ _PROFILES: dict[str, dict[str, Any]] = { "pdf_tool_message": True, "image_tool_message": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "high", }, "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5 (latest)", @@ -355,6 +390,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "pdf_tool_message": True, "image_tool_message": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + "max", + ], + "reasoning_effort_default": "high", }, "claude-sonnet-5": { "name": "Claude Sonnet 5", @@ -381,5 +423,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "pdf_tool_message": True, "image_tool_message": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "high", }, } diff --git a/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml b/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml index 6f8dd96e9a..741df33751 100644 --- a/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml +++ b/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml @@ -16,15 +16,35 @@ structured_output = true [overrides."claude-sonnet-4-6"] structured_output = true +reasoning_effort_levels = ["low", "medium", "high", "max"] +reasoning_effort_default = "high" [overrides."claude-opus-4-1"] structured_output = true [overrides."claude-opus-4-5"] structured_output = true +reasoning_effort_levels = ["low", "medium", "high"] +reasoning_effort_default = "high" + +[overrides."claude-opus-4-5-20251101"] +reasoning_effort_levels = ["low", "medium", "high"] +reasoning_effort_default = "high" [overrides."claude-opus-4-6"] structured_output = true +reasoning_effort_levels = ["low", "medium", "high", "max"] +reasoning_effort_default = "high" [overrides."claude-opus-4-7"] structured_output = true +reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "high" + +[overrides."claude-opus-4-8"] +reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "high" + +[overrides."claude-sonnet-5"] +reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "high" diff --git a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py index 79b5ad2de8..b186cc466d 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py @@ -1321,6 +1321,65 @@ def test_effort_parameter() -> None: assert result.usage_metadata["output_tokens"] > 0 +def test_reasoning_effort_parameter() -> None: + """Test that the standard `reasoning_effort` parameter is accepted by the API.""" + llm = ChatAnthropic( + model="claude-opus-4-5-20251101", + reasoning_effort="medium", + max_tokens=100, + ) + + result = llm.invoke("Say hello in one sentence") + + assert isinstance(result.content, str) + assert len(result.content) > 0 + assert "model_name" in result.response_metadata + assert result.usage_metadata is not None + assert result.usage_metadata["input_tokens"] > 0 + assert result.usage_metadata["output_tokens"] > 0 + + +def test_reasoning_effort_call_time_kwarg() -> None: + """Test that `reasoning_effort` is accepted as a call-time kwarg.""" + llm = ChatAnthropic(model="claude-opus-4-5-20251101", max_tokens=100) + + result = llm.invoke("Say hello in one sentence", reasoning_effort="low") + + assert isinstance(result.content, str) + assert len(result.content) > 0 + assert result.usage_metadata is not None + + +def test_reasoning_effort_defaults_adaptive_thinking() -> None: + """`reasoning_effort` defaults `thinking` to adaptive on models that support it. + + Regression test for a model (Opus 4.7+, Sonnet 5) actually accepting the + resulting `{"type": "adaptive", "display": "summarized"}` thinking config, + not just that the payload is well-formed locally. + """ + llm = ChatAnthropic( + model="claude-opus-4-7", + reasoning_effort="xhigh", + max_tokens=2_000, + ) + + # A genuine multi-step problem: adaptive thinking is model-decided, and a + # trivial prompt (e.g. "what is 3+4") may not surface a visible `thinking` + # block even when accepted, which would make this test flaky. + result = llm.invoke( + "A farmer has 17 sheep. All but 9 die. Then he buys triple the number " + "of remaining sheep, then sells half (rounding down). How many sheep " + "does he have now? Show your reasoning step by step." + ) + + assert isinstance(result.content, list) + assert any( + isinstance(block, dict) and block.get("type") == "thinking" + for block in result.content + ) + assert result.usage_metadata is not None + + def test_image_tool_calling() -> None: """Test tool calling with image inputs.""" diff --git a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py index a1776cb3f1..ad8108a1c0 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -2901,11 +2901,37 @@ def test_effort_in_output_config_payload() -> None: model = ChatAnthropic(model="claude-opus-4-5-20251101", effort="medium") assert model.effort == "medium" - # Test that effort is added to output_config payload = model._get_request_payload("Test query") assert payload["output_config"]["effort"] == "medium" +def test_effort_call_time_kwarg_does_not_warn() -> None: + """Test that a call-time `effort` kwarg never warns. + + `effort` is a permanent alias for `reasoning_effort`, not a deprecated one -- + it's not being removed, so no warning is expected at construction or call + time. + """ + model = ChatAnthropic(model="claude-opus-4-5-20251101") + + with warnings.catch_warnings(): + warnings.simplefilter("error") + payload = model._get_request_payload("Test query", effort="high") + + assert payload["output_config"]["effort"] == "high" + + +def test_reasoning_effort_does_not_warn() -> None: + """Test that the non-deprecated `reasoning_effort` field never warns.""" + model = ChatAnthropic(model="claude-opus-4-5-20251101", reasoning_effort="high") + + with warnings.catch_warnings(): + warnings.simplefilter("error") + payload = model._get_request_payload("Test query") + + assert payload["output_config"]["effort"] == "high" + + def test_effort_in_output_config() -> None: """Test that effort can be specified in `output_config`.""" # Test valid effort in output_config @@ -2926,7 +2952,6 @@ def test_effort_priority() -> None: output_config={"effort": "low"}, ) - # Top-level effort should take precedence in the payload payload = model._get_request_payload("Test query") assert payload["output_config"]["effort"] == "high" @@ -2942,6 +2967,160 @@ def test_output_config_without_effort() -> None: assert payload["output_config"] == {"some_future_param": "value"} +def test_reasoning_effort_parameter_validation() -> None: + """Test that `reasoning_effort` is validated the same as `effort`.""" + model = ChatAnthropic(model="claude-opus-4-5-20251101", reasoning_effort="high") + assert model.reasoning_effort == "high" + + with pytest.raises(ValidationError, match="Input should be"): + ChatAnthropic(model="claude-opus-4-5-20251101", reasoning_effort="invalid") # type: ignore[arg-type] + + +def test_reasoning_effort_in_output_config_payload() -> None: + """Test that a construction-time `reasoning_effort` reaches `output_config`.""" + model = ChatAnthropic(model="claude-opus-4-5-20251101", reasoning_effort="medium") + + payload = model._get_request_payload("Test query") + assert payload["output_config"]["effort"] == "medium" + + +def test_reasoning_effort_as_call_time_kwarg() -> None: + """Test that `reasoning_effort` also works as a call-time keyword argument. + + This is the standard `reasoning_effort` param shared across chat model + integrations, so it must work via `model.invoke(..., reasoning_effort=...)` + without requiring it to be set on the model instance. + """ + model = ChatAnthropic(model="claude-opus-4-5-20251101") + + payload = model._get_request_payload("Test query", reasoning_effort="low") + + assert payload["output_config"]["effort"] == "low" + # Never leaks through as a stray top-level key -- not a real Anthropic field. + assert "reasoning_effort" not in payload + + +def test_reasoning_effort_call_time_kwarg_overrides_construction_time() -> None: + """Test that a call-time `reasoning_effort` overrides the instance default.""" + model = ChatAnthropic(model="claude-opus-4-5-20251101", reasoning_effort="low") + + payload = model._get_request_payload("Test query", reasoning_effort="high") + + assert payload["output_config"]["effort"] == "high" + + +def test_reasoning_effort_yields_to_effort() -> None: + """Test that `effort` still takes precedence over `reasoning_effort`. + + `effort` is a `Field(alias="effort")` on `reasoning_effort`, and Pydantic's + alias-resolution precedence has the alias win when both are supplied. + """ + model = ChatAnthropic( + model="claude-opus-4-5-20251101", + effort="high", + reasoning_effort="low", + ) + + payload = model._get_request_payload("Test query") + assert payload["output_config"]["effort"] == "high" + + +def test_reasoning_effort_defaults_adaptive_thinking() -> None: + """Test that `reasoning_effort` also defaults `thinking` to adaptive. + + Mirrors the reasoning-effort behavior previously implemented client-side + (pairing the effort level with adaptive thinking), so the model actually + reasons harder instead of being told a preference with no active + reasoning mode to apply it to. Only models whose profile advertises + `xhigh` (Opus 4.7+, Sonnet 5) accept this `thinking` shape. + """ + model = ChatAnthropic(model="claude-opus-4-7", reasoning_effort="high") + + payload = model._get_request_payload("Test query") + + assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} + assert payload["output_config"]["effort"] == "high" + + +def test_reasoning_effort_as_call_time_kwarg_defaults_adaptive_thinking() -> None: + """Test that a call-time `reasoning_effort` also defaults `thinking`.""" + model = ChatAnthropic(model="claude-opus-4-7") + + payload = model._get_request_payload("Test query", reasoning_effort="high") + + assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} + + +def test_reasoning_effort_older_model_does_not_default_thinking() -> None: + """Older models must not get an adaptive `thinking` default. + + Regression test: Opus 4.5/4.6 support `reasoning_effort` but reject the + adaptive+summarized `thinking` shape with a 400 from the real API + ("adaptive thinking is not supported on this model"). Only models whose + profile declares `xhigh` support should get the `thinking` default. + """ + model = ChatAnthropic(model="claude-opus-4-5-20251101", reasoning_effort="high") + + payload = model._get_request_payload("Test query") + + assert "thinking" not in payload + assert payload["output_config"]["effort"] == "high" + + +def test_reasoning_effort_preserves_explicit_construction_time_thinking() -> None: + """Test that an explicit `thinking` field is not clobbered by `reasoning_effort`.""" + model = ChatAnthropic( + model="claude-opus-4-5-20251101", + reasoning_effort="high", + thinking={"type": "enabled", "budget_tokens": 10_000}, + ) + + payload = model._get_request_payload("Test query") + + assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10_000} + + +def test_reasoning_effort_preserves_explicit_call_time_thinking() -> None: + """Test that a call-time `thinking` kwarg is not clobbered by `reasoning_effort`.""" + model = ChatAnthropic(model="claude-opus-4-5-20251101") + + payload = model._get_request_payload( + "Test query", + reasoning_effort="high", + thinking={"type": "disabled"}, + ) + + assert payload["thinking"] == {"type": "disabled"} + + +def test_effort_also_defaults_adaptive_thinking() -> None: + """Test that `effort` composes with the adaptive-thinking default too. + + `effort` is a pure alias for `reasoning_effort` (`Field(alias="effort")`), + so they behave identically -- including triggering the adaptive `thinking` + default on `xhigh`-capable models. There's no separate "narrower" behavior + for `effort` anymore, since it's not a separate value. + """ + model = ChatAnthropic(model="claude-opus-4-7", effort="high") + + payload = model._get_request_payload("Test query") + + assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} + + +def test_effort_older_model_does_not_default_thinking() -> None: + """Test that `effort` on a non-`xhigh` model still doesn't default `thinking`. + + Gated on model support (via the model's profile), not on which field name + was used to set the effort level. + """ + model = ChatAnthropic(model="claude-opus-4-5-20251101", effort="high") + + payload = model._get_request_payload("Test query") + + assert "thinking" not in payload + + def test_extras_with_defer_loading() -> None: """Test that extras with `defer_loading` are merged into tool definitions.""" diff --git a/libs/partners/fireworks/langchain_fireworks/chat_models.py b/libs/partners/fireworks/langchain_fireworks/chat_models.py index 30567fa0e2..76ec530ab5 100644 --- a/libs/partners/fireworks/langchain_fireworks/chat_models.py +++ b/libs/partners/fireworks/langchain_fireworks/chat_models.py @@ -895,6 +895,15 @@ class ChatFireworks(BaseChatModel): !!! version-added "Added in `langchain-fireworks` 1.3.0" """ + reasoning_effort: str | None = None + """Reasoning effort. + + Forwarded as the `reasoning_effort` request field. Supported values vary by + model; see the model's `profile.reasoning_effort_levels`. + + Can also be passed at call time, e.g. + `model.invoke(..., reasoning_effort="high")`. + """ model_config = ConfigDict( populate_by_name=True, @@ -994,6 +1003,8 @@ class ChatFireworks(BaseChatModel): params["max_tokens"] = self.max_tokens if self.service_tier is not None: params["service_tier"] = self.service_tier + if self.reasoning_effort is not None: + params["reasoning_effort"] = self.reasoning_effort return params def _get_ls_params( diff --git a/libs/partners/fireworks/langchain_fireworks/data/_profiles.py b/libs/partners/fireworks/langchain_fireworks/data/_profiles.py index fae8f452fc..43279bbafa 100644 --- a/libs/partners/fireworks/langchain_fireworks/data/_profiles.py +++ b/libs/partners/fireworks/langchain_fireworks/data/_profiles.py @@ -59,6 +59,15 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": False, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "high", }, "accounts/fireworks/models/glm-5p1": { "name": "GLM 5.1", @@ -80,6 +89,11 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": False, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "high", + "max", + ], }, "accounts/fireworks/models/glm-5p2": { "name": "GLM 5.2", @@ -101,6 +115,12 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": False, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "high", + "max", + ], + "reasoning_effort_default": "max", }, "accounts/fireworks/models/gpt-oss-120b": { "name": "GPT OSS 120B", @@ -164,6 +184,11 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": True, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + ], }, "accounts/fireworks/models/kimi-k2p7-code": { "name": "Kimi K2.7 Code", @@ -185,6 +210,11 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": True, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + ], }, "accounts/fireworks/models/minimax-m2p7": { "name": "MiniMax-M2.7", @@ -269,6 +299,11 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": False, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "high", + "max", + ], }, "accounts/fireworks/routers/glm-5p2-fast": { "name": "GLM 5.2 Fast", @@ -290,6 +325,12 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": False, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "high", + "max", + ], + "reasoning_effort_default": "max", }, "accounts/fireworks/routers/kimi-k2p6-fast": { "name": "Kimi K2.6 Fast", @@ -311,6 +352,11 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": True, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + ], }, "accounts/fireworks/routers/kimi-k2p6-turbo": { "name": "Kimi K2.6 Turbo", @@ -332,6 +378,11 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": True, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + ], }, "accounts/fireworks/routers/kimi-k2p7-code-fast": { "name": "Kimi K2.7 Code Fast", @@ -353,5 +404,10 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": True, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "low", + "medium", + "high", + ], }, } diff --git a/libs/partners/fireworks/langchain_fireworks/data/profile_augmentations.toml b/libs/partners/fireworks/langchain_fireworks/data/profile_augmentations.toml index 2c19b9ed32..8cacec04fb 100644 --- a/libs/partners/fireworks/langchain_fireworks/data/profile_augmentations.toml +++ b/libs/partners/fireworks/langchain_fireworks/data/profile_augmentations.toml @@ -2,3 +2,36 @@ provider = "fireworks-ai" [overrides] tool_call_streaming = true + +[overrides."accounts/fireworks/models/deepseek-v4-pro"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "high" + +[overrides."accounts/fireworks/models/glm-5p1"] +reasoning_effort_levels = ["none", "high", "max"] + +[overrides."accounts/fireworks/models/glm-5p2"] +reasoning_effort_levels = ["none", "high", "max"] +reasoning_effort_default = "max" + +[overrides."accounts/fireworks/models/kimi-k2p6"] +reasoning_effort_levels = ["low", "medium", "high"] + +[overrides."accounts/fireworks/models/kimi-k2p7-code"] +reasoning_effort_levels = ["low", "medium", "high"] + +[overrides."accounts/fireworks/routers/glm-5p1-fast"] +reasoning_effort_levels = ["none", "high", "max"] + +[overrides."accounts/fireworks/routers/glm-5p2-fast"] +reasoning_effort_levels = ["none", "high", "max"] +reasoning_effort_default = "max" + +[overrides."accounts/fireworks/routers/kimi-k2p6-fast"] +reasoning_effort_levels = ["low", "medium", "high"] + +[overrides."accounts/fireworks/routers/kimi-k2p6-turbo"] +reasoning_effort_levels = ["low", "medium", "high"] + +[overrides."accounts/fireworks/routers/kimi-k2p7-code-fast"] +reasoning_effort_levels = ["low", "medium", "high"] diff --git a/libs/partners/fireworks/tests/integration_tests/test_chat_models.py b/libs/partners/fireworks/tests/integration_tests/test_chat_models.py index 31ee824f73..eb6d93ef5a 100644 --- a/libs/partners/fireworks/tests/integration_tests/test_chat_models.py +++ b/libs/partners/fireworks/tests/integration_tests/test_chat_models.py @@ -178,3 +178,33 @@ def test_structured_output_json_schema(schema_type: str) -> None: validation_function(chunk) chunks.append(chunk) assert chunk + + +def test_reasoning_effort_parameter() -> None: + """Test that the standard `reasoning_effort` parameter is accepted by the API.""" + llm = ChatFireworks( + model="accounts/fireworks/models/kimi-k2p6", + reasoning_effort="high", + rate_limiter=rate_limiter, + ) + + result = llm.invoke("Say hello in one sentence") + + assert isinstance(result.content, str) + assert len(result.content) > 0 + assert result.usage_metadata is not None + assert result.usage_metadata["input_tokens"] > 0 + assert result.usage_metadata["output_tokens"] > 0 + + +def test_reasoning_effort_call_time_kwarg() -> None: + """Test that `reasoning_effort` is accepted as a call-time kwarg.""" + llm = ChatFireworks( + model="accounts/fireworks/models/kimi-k2p6", rate_limiter=rate_limiter + ) + + result = llm.invoke("Say hello in one sentence", reasoning_effort="high") + + assert isinstance(result.content, str) + assert len(result.content) > 0 + assert result.usage_metadata is not None diff --git a/libs/partners/fireworks/tests/unit_tests/test_chat_models.py b/libs/partners/fireworks/tests/unit_tests/test_chat_models.py index 6f2df3b831..84906675ff 100644 --- a/libs/partners/fireworks/tests/unit_tests/test_chat_models.py +++ b/libs/partners/fireworks/tests/unit_tests/test_chat_models.py @@ -1524,6 +1524,90 @@ class TestStreamUsage: } +class TestReasoningEffort: + """Tests for the `reasoning_effort` field plumbing.""" + + def test_reasoning_effort_omitted_by_default(self) -> None: + model = _make_model() + assert "reasoning_effort" not in model._default_params + + def test_reasoning_effort_in_default_params_when_set(self) -> None: + model = _make_model(reasoning_effort="high") + assert model._default_params["reasoning_effort"] == "high" + + def test_reasoning_effort_passed_to_client_when_set(self) -> None: + model = _make_model(reasoning_effort="high") + model.client = MagicMock() + model.client.create.return_value = { + "choices": [ + { + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + model.invoke("Hello") + call_kwargs = model.client.create.call_args[1] + assert call_kwargs["reasoning_effort"] == "high" + + def test_reasoning_effort_not_passed_when_unset(self) -> None: + model = _make_model() + model.client = MagicMock() + model.client.create.return_value = { + "choices": [ + { + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + model.invoke("Hello") + call_kwargs = model.client.create.call_args[1] + assert "reasoning_effort" not in call_kwargs + + def test_reasoning_effort_as_call_time_kwarg(self) -> None: + """`reasoning_effort` also works as a call-time keyword argument. + + This is the standard `reasoning_effort` param shared across chat model + integrations, so it must work via `model.invoke(..., reasoning_effort=...)` + without requiring it to be set on the model instance. + """ + model = _make_model() + model.client = MagicMock() + model.client.create.return_value = { + "choices": [ + { + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + model.invoke("Hello", reasoning_effort="low") + call_kwargs = model.client.create.call_args[1] + assert call_kwargs["reasoning_effort"] == "low" + + def test_reasoning_effort_call_time_kwarg_overrides_construction_time( + self, + ) -> None: + model = _make_model(reasoning_effort="low") + model.client = MagicMock() + model.client.create.return_value = { + "choices": [ + { + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + model.invoke("Hello", reasoning_effort="high") + call_kwargs = model.client.create.call_args[1] + assert call_kwargs["reasoning_effort"] == "high" + + class TestServiceTier: """Tests for the `service_tier` field plumbing.""" diff --git a/libs/partners/openai/langchain_openai/data/_profiles.py b/libs/partners/openai/langchain_openai/data/_profiles.py index d011874781..46db8e9c51 100644 --- a/libs/partners/openai/langchain_openai/data/_profiles.py +++ b/libs/partners/openai/langchain_openai/data/_profiles.py @@ -365,6 +365,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5-chat-latest": { "name": "GPT-5 Chat (latest)", @@ -392,6 +399,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5-codex": { "name": "GPT-5-Codex", @@ -419,6 +433,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5-mini": { "name": "GPT-5 Mini", @@ -446,6 +467,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5-nano": { "name": "GPT-5 Nano", @@ -473,6 +501,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5-pro": { "name": "GPT-5 Pro", @@ -500,6 +535,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.1": { "name": "GPT-5.1", @@ -527,6 +569,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.1-chat-latest": { "name": "GPT-5.1 Chat", @@ -554,6 +603,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.1-codex": { "name": "GPT-5.1 Codex", @@ -581,6 +637,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.1-codex-max": { "name": "GPT-5.1 Codex Max", @@ -608,6 +671,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.1-codex-mini": { "name": "GPT-5.1 Codex mini", @@ -635,6 +705,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.2": { "name": "GPT-5.2", @@ -662,6 +739,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.2-chat-latest": { "name": "GPT-5.2 Chat", @@ -689,6 +773,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.2-codex": { "name": "GPT-5.2 Codex", @@ -716,6 +807,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.2-pro": { "name": "GPT-5.2 Pro", @@ -743,6 +841,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.3-chat-latest": { "name": "GPT-5.3 Chat (latest)", @@ -770,6 +875,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.3-codex": { "name": "GPT-5.3 Codex", @@ -797,6 +909,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.3-codex-spark": { "name": "GPT-5.3 Codex Spark", @@ -824,6 +943,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.4": { "name": "GPT-5.4", @@ -851,6 +977,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.4-mini": { "name": "GPT-5.4 mini", @@ -878,6 +1011,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.4-nano": { "name": "GPT-5.4 nano", @@ -905,6 +1045,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.4-pro": { "name": "GPT-5.4 Pro", @@ -932,6 +1079,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], }, "gpt-5.5": { "name": "GPT-5.5", @@ -959,6 +1113,14 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], + "reasoning_effort_default": "medium", }, "gpt-5.5-pro": { "name": "GPT-5.5 Pro", @@ -986,6 +1148,14 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + ], + "reasoning_effort_default": "medium", }, "gpt-5.6": { "name": "GPT-5.6", @@ -1013,6 +1183,15 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "medium", }, "gpt-5.6-luna": { "name": "GPT-5.6 Luna", @@ -1040,6 +1219,15 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "medium", }, "gpt-5.6-sol": { "name": "GPT-5.6 Sol", @@ -1067,6 +1255,15 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "medium", }, "gpt-5.6-terra": { "name": "GPT-5.6 Terra", @@ -1094,6 +1291,15 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_choice": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + ], + "reasoning_effort_default": "medium", }, "gpt-image-1": { "name": "gpt-image-1", diff --git a/libs/partners/openai/langchain_openai/data/profile_augmentations.toml b/libs/partners/openai/langchain_openai/data/profile_augmentations.toml index ad38e80103..c5e8838771 100644 --- a/libs/partners/openai/langchain_openai/data/profile_augmentations.toml +++ b/libs/partners/openai/langchain_openai/data/profile_augmentations.toml @@ -16,42 +16,104 @@ image_tool_message = false [overrides."gpt-5.1-codex"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5.2-pro"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5.1-codex-mini"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5.2-chat-latest"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5.1"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5-nano"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5-codex"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5-mini"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5.1-codex-max"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5-chat-latest"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5-pro"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5.2"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] [overrides."gpt-5.1-chat-latest"] max_input_tokens = 272000 +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.2-codex"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.3-chat-latest"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.3-codex"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.3-codex-spark"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.4"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.4-mini"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.4-nano"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.4-pro"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] + +[overrides."gpt-5.5"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] +reasoning_effort_default = "medium" + +[overrides."gpt-5.5-pro"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh"] +reasoning_effort_default = "medium" + +[overrides."gpt-5.6"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "medium" + +[overrides."gpt-5.6-luna"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "medium" + +[overrides."gpt-5.6-sol"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "medium" + +[overrides."gpt-5.6-terra"] +reasoning_effort_levels = ["none", "low", "medium", "high", "xhigh", "max"] +reasoning_effort_default = "medium" 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 063cbc16c1..5ca28f3384 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 @@ -1072,6 +1072,30 @@ def test_reasoning_model_stream_default_works() -> None: assert len(result) > 0 +def test_reasoning_effort_parameter() -> None: + """Test that the standard `reasoning_effort` parameter is accepted by the API.""" + llm = ChatOpenAI(model="gpt-5-nano", reasoning_effort="low") + + result = llm.invoke("Say hello in one sentence") + + assert isinstance(result.content, str) + assert len(result.content) > 0 + assert result.usage_metadata is not None + assert result.usage_metadata["input_tokens"] > 0 + assert result.usage_metadata["output_tokens"] > 0 + + +def test_reasoning_effort_call_time_kwarg() -> None: + """Test that `reasoning_effort` is accepted as a call-time kwarg.""" + llm = ChatOpenAI(model="gpt-5-nano") + + result = llm.invoke("Say hello in one sentence", reasoning_effort="low") + + assert isinstance(result.content, str) + assert len(result.content) > 0 + assert result.usage_metadata is not None + + @pytest.mark.flaky(retries=3, delay=1) def test_multi_party_conversation() -> None: llm = ChatOpenAI(model="gpt-5-nano") 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 e4df8fd473..09159f79af 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 @@ -1547,7 +1547,7 @@ def test_minimal_reasoning_effort_payload( # When using responses API, reasoning_effort becomes reasoning.effort if use_responses_api: assert "reasoning" in payload - assert payload["reasoning"]["effort"] == "minimal" + assert payload["reasoning"] == {"effort": "minimal"} # For responses API, tokens param becomes max_output_tokens assert payload["max_output_tokens"] == 100 else: @@ -4616,6 +4616,39 @@ def test_namespace_passthrough() -> None: assert {"type": "tool_search"} in payload["tools"] +def test_reasoning_effort_responses_api_maps_to_effort_only() -> None: + """Test `reasoning_effort` maps to `reasoning.effort` alone, no `summary`. + + `summary` is a separate concern, configured via the `reasoning` param + directly (e.g. `reasoning={"effort": "high", "summary": "auto"}`) rather + than implied by `reasoning_effort`. + """ + from langchain_openai.chat_models.base import _construct_responses_api_payload + + payload = _construct_responses_api_payload([], {"reasoning_effort": "high"}) + + assert payload["reasoning"] == {"effort": "high"} + assert "reasoning_effort" not in payload + + +def test_reasoning_effort_responses_api_preserves_existing_reasoning() -> None: + """Test that an already-present `reasoning` dict is not overwritten.""" + from langchain_openai.chat_models.base import _construct_responses_api_payload + + payload = _construct_responses_api_payload( + [], + { + "reasoning_effort": "high", + "reasoning": {"effort": "low", "summary": "concise"}, + }, + ) + + assert payload["reasoning"] == {"effort": "low", "summary": "concise"} + # The unused `reasoning_effort` kwarg is left untouched in this case, since + # the guard requires `"reasoning" not in payload` before popping it. + assert payload["reasoning_effort"] == "high" + + def test_defer_loading_in_responses_api_payload() -> None: """Test that defer_loading is preserved in Responses API tool format.""" from langchain_openai.chat_models.base import _construct_responses_api_payload diff --git a/libs/partners/xai/langchain_xai/chat_models.py b/libs/partners/xai/langchain_xai/chat_models.py index 7192dad7b2..38a40680ce 100644 --- a/libs/partners/xai/langchain_xai/chat_models.py +++ b/libs/partners/xai/langchain_xai/chat_models.py @@ -586,6 +586,16 @@ class ChatXAI(BaseChatOpenAI): # type: ignore[override] ) if rejects_stop: payload.pop("stop", None) + + # xAI expects `reasoning_effort` in `extra_body`, not as a top-level field. + # Move it there so it reaches the API. + reasoning_effort = payload.pop("reasoning_effort", None) + if reasoning_effort is not None: + extra_body = payload.get("extra_body") + if not isinstance(extra_body, dict): + extra_body = {} + payload["extra_body"] = {**extra_body, "reasoning_effort": reasoning_effort} + return payload def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGenerationChunk]: diff --git a/libs/partners/xai/langchain_xai/data/_profiles.py b/libs/partners/xai/langchain_xai/data/_profiles.py index d585d3654f..bed794296d 100644 --- a/libs/partners/xai/langchain_xai/data/_profiles.py +++ b/libs/partners/xai/langchain_xai/data/_profiles.py @@ -107,6 +107,13 @@ _PROFILES: dict[str, dict[str, Any]] = { "attachment": True, "temperature": True, "tool_call_streaming": True, + "reasoning_effort_levels": [ + "none", + "low", + "medium", + "high", + ], + "reasoning_effort_default": "low", }, "grok-4.5": { "name": "Grok 4.5", diff --git a/libs/partners/xai/langchain_xai/data/profile_augmentations.toml b/libs/partners/xai/langchain_xai/data/profile_augmentations.toml index 1403df685f..7b39fe8d72 100644 --- a/libs/partners/xai/langchain_xai/data/profile_augmentations.toml +++ b/libs/partners/xai/langchain_xai/data/profile_augmentations.toml @@ -2,3 +2,7 @@ provider = "xai" [overrides] tool_call_streaming = true + +[overrides."grok-4.3"] +reasoning_effort_levels = ["none", "low", "medium", "high"] +reasoning_effort_default = "low" diff --git a/libs/partners/xai/tests/integration_tests/test_chat_models.py b/libs/partners/xai/tests/integration_tests/test_chat_models.py index 4846eebece..8faaa4e8a9 100644 --- a/libs/partners/xai/tests/integration_tests/test_chat_models.py +++ b/libs/partners/xai/tests/integration_tests/test_chat_models.py @@ -97,6 +97,17 @@ def test_reasoning(output_version: Literal["", "v1"]) -> None: assert followup_2.additional_kwargs["reasoning_content"] +def test_reasoning_effort_call_time_kwarg() -> None: + """Test that `reasoning_effort` is accepted as a call-time kwarg.""" + chat_model = ChatXAI(model="grok-3-mini", temperature=0) + + response = chat_model.invoke("What is 3^3?", reasoning_effort="low") + + assert response.content + assert response.additional_kwargs["reasoning_content"] + assert response.usage_metadata is not None + + def test_web_search() -> None: llm = ChatXAI(model=MODEL_NAME, temperature=0).bind_tools([{"type": "web_search"}]) diff --git a/libs/partners/xai/tests/unit_tests/test_chat_models.py b/libs/partners/xai/tests/unit_tests/test_chat_models.py index 66c949c3f3..817b01ee5f 100644 --- a/libs/partners/xai/tests/unit_tests/test_chat_models.py +++ b/libs/partners/xai/tests/unit_tests/test_chat_models.py @@ -130,6 +130,67 @@ def test_non_reasoning_model_payload_keeps_stop() -> None: assert payload["stop"] == ["END"] +def test_reasoning_effort_moved_to_extra_body() -> None: + """`reasoning_effort` (inherited from `BaseChatOpenAI`) must reach xAI's + API via `extra_body`, since xAI does not accept it as a top-level field. + """ + llm = ChatXAI( + model="grok-3-mini", + api_key=SecretStr("test-api-key"), + reasoning_effort="high", + ) + + payload = llm._get_request_payload("hello") + + assert "reasoning_effort" not in payload + assert payload["extra_body"]["reasoning_effort"] == "high" + + +def test_reasoning_effort_as_call_time_kwarg() -> None: + """`reasoning_effort` also works as a call-time keyword argument. + + This is the standard `reasoning_effort` param shared across chat model + integrations, so it must work via `model.invoke(..., reasoning_effort=...)` + without requiring it to be set on the model instance. + """ + llm = ChatXAI(model="grok-3-mini", api_key=SecretStr("test-api-key")) + + payload = llm._get_request_payload("hello", reasoning_effort="low") + + assert "reasoning_effort" not in payload + assert payload["extra_body"]["reasoning_effort"] == "low" + + +def test_reasoning_effort_preserves_existing_extra_body() -> None: + """Moving `reasoning_effort` into `extra_body` must not drop sibling keys.""" + llm = ChatXAI( + model="grok-3-mini", + api_key=SecretStr("test-api-key"), + reasoning_effort="high", + extra_body={"some_other_field": "value"}, + ) + + payload = llm._get_request_payload("hello") + + assert payload["extra_body"] == { + "some_other_field": "value", + "reasoning_effort": "high", + } + + +def test_no_reasoning_effort_leaves_extra_body_untouched() -> None: + llm = ChatXAI( + model="grok-3-mini", + api_key=SecretStr("test-api-key"), + extra_body={"some_other_field": "value"}, + ) + + payload = llm._get_request_payload("hello") + + assert payload["extra_body"] == {"some_other_field": "value"} + assert "reasoning_effort" not in payload + + def test_function_dict_to_message_function_message() -> None: content = json.dumps({"result": "Example #1"}) name = "test_function"