From 4a42158e6cba6c18bb4acb3589978ffc63c53f43 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Fri, 5 Dec 2025 13:44:42 -0500 Subject: [PATCH] feat(anthropic): add `effort` support (#34116) --- .../tests/unit_tests/chat_models/test_base.py | 1 + .../chat_models/test_chat_models.py | 1 + .../langchain_anthropic/chat_models.py | 92 +++++++++++++-- libs/partners/anthropic/pyproject.toml | 2 +- .../integration_tests/test_chat_models.py | 24 ++++ .../tests/unit_tests/test_chat_models.py | 108 +++++++++++++++++- libs/partners/anthropic/uv.lock | 8 +- 7 files changed, 218 insertions(+), 18 deletions(-) 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 2b769aa4a38..4b1a4147e46 100644 --- a/libs/langchain/tests/unit_tests/chat_models/test_base.py +++ b/libs/langchain/tests/unit_tests/chat_models/test_base.py @@ -256,6 +256,7 @@ def test_configurable_with_default() -> None: "max_tokens": 64000, "temperature": None, "thinking": None, + "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 9c1f6be5558..18a96fff2ee 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 @@ -251,6 +251,7 @@ def test_configurable_with_default() -> None: "bound": { "name": None, "disable_streaming": False, + "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 5f1da7be03f..e04485c6970 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -75,8 +75,18 @@ _MODEL_PROFILES = cast(ModelProfileRegistry, _PROFILES) def _get_default_model_profile(model_name: str) -> ModelProfile: - default = _MODEL_PROFILES.get(model_name) or {} - return default.copy() + """Get the default profile for a model. + + Args: + model_name: The model identifier. + + Returns: + The model profile dictionary, or an empty dict if not found. + """ + default = _MODEL_PROFILES.get(model_name) + if default: + return default.copy() + return {} _MODEL_DEFAULT_MAX_OUTPUT_TOKENS: Final[dict[str, int]] = { @@ -1056,6 +1066,29 @@ class ChatAnthropic(BaseChatModel): Refer to the [Claude docs](https://platform.claude.com/docs/en/build-with-claude/extended-thinking#differences-in-thinking-across-model-versions) for more info. + ???+ example "Effort" + + Certain Claude models support an [effort](https://platform.claude.com/docs/en/build-with-claude/effort) + feature, which will control how many tokens Claude uses when responding. + + !!! example + + ```python hl_lines="6" + from langchain_anthropic import ChatAnthropic + + model = ChatAnthropic( + model="claude-opus-4-5-20251101", + max_tokens=4096, + effort="medium", # Options: "high", "medium", "low" + ) + + response = model.invoke("Analyze the trade-offs between microservices and monolithic architectures") + print(response.content) + ``` + + See the [Claude docs](https://platform.claude.com/docs/en/build-with-claude/effort) + for more detail on when to use different effort levels. + ???+ example "Prompt caching" Prompt caching reduces processing time and costs for repetitive tasks or prompts @@ -1638,26 +1671,40 @@ class ChatAnthropic(BaseChatModel): e.g., `#!python {"type": "enabled", "budget_tokens": 10_000}` """ + effort: Literal["high", "medium", "low"] | None = None + """Control how many tokens Claude uses when responding. + + This parameter will be merged into the `output_config` parameter when making + API calls. + + Example: `effort="medium"` + + !!! note + + Setting `effort` to `'high'` produces exactly the same behavior as omitting the + parameter altogether. + + !!! note "Model Support" + + This feature is currently only supported by Claude Opus 4.5. + + !!! note "Automatic beta header" + + The required `effort-2025-11-24` beta header is + automatically appended to the request when using `effort`, so you + don't need to manually specify it in the `betas` parameter. + """ + mcp_servers: list[dict[str, Any]] | None = None """List of MCP servers to use for the request. Example: `#!python mcp_servers=[{"type": "url", "url": "https://mcp.example.com/mcp", "name": "example-mcp"}]` - - !!! note - - This feature requires the beta header `'mcp-client-2025-11-20'` to be set in - [`betas`][langchain_anthropic.chat_models.ChatAnthropic.betas]. """ context_management: dict[str, Any] | None = None """Configuration for [context management](https://platform.claude.com/docs/en/build-with-claude/context-editing). - - !!! note - - This feature requires the beta header `'context-management-2025-06-27'` to be - set in [`betas`][langchain_anthropic.chat_models.ChatAnthropic.betas]. """ @property @@ -1868,6 +1915,27 @@ class ChatAnthropic(BaseChatModel): if self.thinking is not None: payload["thinking"] = self.thinking + # Handle output_config and effort parameter + # Priority: self.effort > payload output_config + output_config = payload.get("output_config", {}) + output_config = output_config.copy() if isinstance(output_config, dict) else {} + + if self.effort: + output_config["effort"] = self.effort + + if output_config: + payload["output_config"] = output_config + + # Auto-append required beta for effort + if "effort" in output_config: + required_beta = "effort-2025-11-24" + if payload["betas"]: + # Merge with existing betas + if required_beta not in payload["betas"]: + payload["betas"] = [*payload["betas"], required_beta] + else: + payload["betas"] = [required_beta] + if "response_format" in payload: # response_format present when using agents.create_agent's ProviderStrategy # --- diff --git a/libs/partners/anthropic/pyproject.toml b/libs/partners/anthropic/pyproject.toml index 1109000aa25..432c700b7ed 100644 --- a/libs/partners/anthropic/pyproject.toml +++ b/libs/partners/anthropic/pyproject.toml @@ -12,7 +12,7 @@ authors = [] version = "1.2.0" requires-python = ">=3.10.0,<4.0.0" dependencies = [ - "anthropic>=0.73.0,<1.0.0", + "anthropic>=0.75.0,<1.0.0", "langchain-core>=1.1.0,<2.0.0", "pydantic>=2.7.4,<3.0.0", ] 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 acd1a64e119..127a0f19191 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py @@ -1152,6 +1152,30 @@ def test_structured_output_thinking_force_tool_use() -> None: llm.invoke("Generate a username for Sally with green hair") +def test_effort_parameter() -> None: + """Test that effort parameter can be passed without errors. + + Only Opus 4.5 supports currently. + """ + llm = ChatAnthropic( + model="claude-opus-4-5-20251101", + effort="medium", + max_tokens=100, + ) + + result = llm.invoke("Say hello in one sentence") + + # Verify we got a response + assert isinstance(result.content, str) + assert len(result.content) > 0 + + # Verify response metadata is present + 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_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 8506f1a1ff0..d0da0d0b0fd 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -16,7 +16,7 @@ from langchain_core.runnables import RunnableBinding from langchain_core.tools import BaseTool from langchain_core.tracers.base import BaseTracer from langchain_core.tracers.schemas import Run -from pydantic import BaseModel, Field, SecretStr +from pydantic import BaseModel, Field, SecretStr, ValidationError from pytest import CaptureFixture, MonkeyPatch from langchain_anthropic import ChatAnthropic @@ -1906,3 +1906,109 @@ async def test_model_profile_not_blocking() -> None: with blockbuster_ctx(): model = ChatAnthropic(model="claude-sonnet-4-5") _ = model.profile + + +def test_effort_parameter_validation() -> None: + """Test that effort parameter is validated correctly. + + The effort parameter is currently in beta and only supported by Claude Opus 4.5. + """ + # Valid effort values should work + model = ChatAnthropic(model="claude-opus-4-5-20251101", effort="high") + assert model.effort == "high" + + model = ChatAnthropic(model="claude-opus-4-5-20251101", effort="medium") + assert model.effort == "medium" + + model = ChatAnthropic(model="claude-opus-4-5-20251101", effort="low") + assert model.effort == "low" + + # Invalid effort values should raise ValidationError + with pytest.raises(ValidationError, match="Input should be"): + ChatAnthropic(model="claude-opus-4-5-20251101", effort="invalid") # type: ignore[arg-type] + + +def test_effort_populates_betas() -> None: + """Test that effort parameter auto-populates required betas.""" + model = ChatAnthropic(model="claude-opus-4-5-20251101", effort="medium") + assert model.effort == "medium" + + # Test that effort works with dated API ID + payload = model._get_request_payload("Test query") + assert payload["output_config"]["effort"] == "medium" + assert "effort-2025-11-24" in payload["betas"] + + +def test_effort_in_output_config() -> None: + """Test that effort can be specified in `output_config`.""" + # Test valid effort in output_config + model = ChatAnthropic( + model="claude-opus-4-5-20251101", + output_config={"effort": "low"}, + ) + assert model.model_kwargs["output_config"] == {"effort": "low"} + + +def test_effort_priority() -> None: + """Test that top-level effort takes precedence over `output_config`.""" + model = ChatAnthropic( + model="claude-opus-4-5-20251101", + effort="high", + 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" + + +def test_effort_beta_header_auto_append() -> None: + """Test that effort beta header is automatically appended.""" + # Test with top-level effort parameter + model = ChatAnthropic(model="claude-opus-4-5-20251101", effort="medium") + payload = model._get_request_payload("Test query") + assert "effort-2025-11-24" in payload["betas"] + + # Test with output_config + model = ChatAnthropic( + model="claude-opus-4-5-20251101", + output_config={"effort": "low"}, + ) + payload = model._get_request_payload("Test query") + assert "effort-2025-11-24" in payload["betas"] + + # Test that beta is not duplicated if already present + model = ChatAnthropic( + model="claude-opus-4-5-20251101", + effort="high", + betas=["effort-2025-11-24"], + ) + payload = model._get_request_payload("Test query") + assert payload["betas"].count("effort-2025-11-24") == 1 + + # Test combining effort with other betas + model = ChatAnthropic( + model="claude-opus-4-5-20251101", + effort="medium", + betas=["context-1m-2025-08-07"], + ) + payload = model._get_request_payload("Test query") + assert set(payload["betas"]) == { + "context-1m-2025-08-07", + "effort-2025-11-24", + } + + +def test_output_config_without_effort() -> None: + """Test that output_config can be used without effort.""" + # output_config might have other fields in the future + model = ChatAnthropic( + model=MODEL_NAME, + output_config={"some_future_param": "value"}, + ) + payload = model._get_request_payload("Test query") + assert payload["output_config"] == {"some_future_param": "value"} + # No effort beta should be added + assert payload.get("betas") is None or "effort-2025-11-24" not in payload.get( + "betas", [] + ) diff --git a/libs/partners/anthropic/uv.lock b/libs/partners/anthropic/uv.lock index ee815c36783..43376f3c173 100644 --- a/libs/partners/anthropic/uv.lock +++ b/libs/partners/anthropic/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.74.0" +version = "0.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -33,9 +33,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f9/baa1b885c8664b446e6a13003938046901e54ffd70b532bbebd01256e34b/anthropic-0.74.0.tar.gz", hash = "sha256:114ec10cb394b6764e199da06335da4747b019c5629e53add33572f66964ad99", size = 428958, upload-time = "2025-11-18T15:29:47.579Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/27/8c404b290ec650e634eacc674df943913722ec21097b0476d68458250c2f/anthropic-0.74.0-py3-none-any.whl", hash = "sha256:df29b8dfcdbd2751fa31177f643d8d8f66c5315fe06bdc42f9139e9f00d181d5", size = 371474, upload-time = "2025-11-18T15:29:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" }, ] [[package]] @@ -604,7 +604,7 @@ typing = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.73.0,<1.0.0" }, + { name = "anthropic", specifier = ">=0.75.0,<1.0.0" }, { name = "langchain-core", editable = "../../core" }, { name = "pydantic", specifier = ">=2.7.4,<3.0.0" }, ]