fix(anthropic): auto-route with_structured_output to method="json_schema" for fable and opus 5.5 (#40766)

This commit is contained in:
ccurme authored and GitHub committed 2026-09-22 14:24:51 -04:00
1 parent 3971e49d24
commit fbac0b2d0c
2 files changed
+72 -8

No files matched your search

@@ -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,
@@ -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]