diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index ca0addb5f6..f9541d7052 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -993,6 +993,11 @@ def _supports_mid_conversation_system_messages(model: object) -> bool: ) +def _supports_forced_tool_choice(model: str) -> bool: + """Return whether the model accepts `tool_choice` types `any` and `tool`.""" + return not model.startswith(("claude-fable-5-1", "claude-opus-5-5")) + + def _is_direct_anthropic_llm_type(llm_type: object) -> bool: """Return whether an `_llm_type` reaches Claude via the direct Anthropic API. @@ -2394,9 +2399,11 @@ class ChatAnthropic(BaseChatModel): ) -> Runnable[LanguageModelInput, BaseMessage]: thinking_admonition = ( "You are attempting to use structured output via forced tool calling, " - "which is not guaranteed when `thinking` is enabled. This method will " - "raise an OutputParserException if tool calls are not generated. Consider " - "disabling `thinking` or adjust your prompt to ensure the tool is called." + "which is not supported when `thinking` is enabled or on " + f"{self.model}. This method will raise an OutputParserException if tool " + "calls are not generated. Consider `method='json_schema'`, disabling " + "`thinking` where supported, or adjusting your prompt to ensure the " + "tool is called." ) warnings.warn(thinking_admonition, stacklevel=2) llm = self.bind_tools( @@ -2667,7 +2674,10 @@ class ChatAnthropic(BaseChatModel): method: The structured output method to use. Options are: - `'function_calling'` (default): Use forced tool calling to get - structured output. + structured output. When `thinking` is enabled, or on models + that don't support forced tool use (Claude Opus 5.5, Claude + Fable 5.1), the tool call isn't forced, and a missing tool + call raises `OutputParserException`. - `'json_schema'`: Use Claude's dedicated [structured output](https://platform.claude.com/docs/en/build-with-claude/structured-outputs) feature. @@ -2717,15 +2727,16 @@ class ChatAnthropic(BaseChatModel): warnings.warn(warning_message, stacklevel=2) method = "json_schema" + # TODO: make `method="json_schema"` the default in a future release. if method == "function_calling": formatted_tool = cast(AnthropicTool, convert_to_anthropic_tool(schema)) # The result of convert_to_anthropic_tool for 'method=function_calling' will # always be an AnthropicTool tool_name = formatted_tool["name"] - if self.thinking is not None and self.thinking.get("type") in ( - "enabled", - "adaptive", - ): + if ( + self.thinking is not None + and self.thinking.get("type") in ("enabled", "adaptive") + ) or not _supports_forced_tool_choice(self.model): llm = self._get_llm_for_structured_output_when_thinking_is_enabled( schema, formatted_tool, 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 be9ca01d4b..3a9d67905c 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -2707,6 +2707,59 @@ def test_with_structured_output_root_combinator_raises_when_thinking_enabled() - chat_model.with_structured_output(_Either, method="function_calling") +class _Person(BaseModel): + name: str + + +_ANTHROPIC_TOOL_SCHEMA = { + "name": "_Person", + "input_schema": {"type": "object", "properties": {"name": {"type": "string"}}}, +} + + +@pytest.mark.parametrize("model", ["claude-opus-5-5", "claude-fable-5-1"]) +@pytest.mark.parametrize("schema", [_Person, _ANTHROPIC_TOOL_SCHEMA]) +@pytest.mark.parametrize("thinking", [None, {"type": "adaptive"}]) +def test_with_structured_output_skips_forced_tool_choice_when_unsupported( + model: str, + schema: type[BaseModel] | dict[str, Any], + thinking: dict[str, Any] | None, +) -> None: + """Models that reject forced `tool_choice` bind the tool without forcing it.""" + chat_model = ChatAnthropic( # type: ignore[call-arg, call-arg] + model=model, + anthropic_api_key="secret-api-key", + thinking=thinking, + ) + + with pytest.warns(UserWarning, match="method='json_schema'"): + structured = chat_model.with_structured_output(schema) + + bound = cast("RunnableBinding", structured.first) # type: ignore[attr-defined] + assert [t["name"] for t in bound.kwargs["tools"]] == ["_Person"] + assert "tool_choice" not in bound.kwargs + assert "output_config" not in bound.kwargs + + +def test_with_structured_output_forces_tool_choice_when_supported() -> None: + """Models that accept forced `tool_choice` keep `function_calling`.""" + chat_model = ChatAnthropic( # type: ignore[call-arg, call-arg] + model="claude-opus-5", + anthropic_api_key="secret-api-key", + ) + + class Person(BaseModel): + name: str + + with warnings.catch_warnings(): + warnings.simplefilter("error") + structured = chat_model.with_structured_output(Person) + + bound = cast("RunnableBinding", structured.first) # type: ignore[attr-defined] + assert bound.kwargs["tool_choice"] == {"type": "tool", "name": "Person"} + assert "output_config" not in bound.kwargs + + def test_get_num_tokens_from_messages_filters_unsupported_tools() -> None: """Token counting and sending agree on which tools the API will accept.""" chat_model = ChatAnthropic( # type: ignore[call-arg, call-arg]