diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index 08b846c067f..c4fbd15c711 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -32,13 +32,21 @@ from langchain_core.messages import ( from langchain_core.messages import content as types from langchain_core.messages.ai import InputTokenDetails, UsageMetadata from langchain_core.messages.tool import tool_call_chunk as create_tool_call_chunk -from langchain_core.output_parsers import JsonOutputKeyToolsParser, PydanticToolsParser +from langchain_core.output_parsers import ( + JsonOutputKeyToolsParser, + JsonOutputParser, + PydanticOutputParser, + PydanticToolsParser, +) from langchain_core.output_parsers.base import OutputParserLike from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.runnables import Runnable, RunnableMap, RunnablePassthrough from langchain_core.tools import BaseTool from langchain_core.utils import from_env, get_pydantic_field_names, secret_from_env -from langchain_core.utils.function_calling import convert_to_openai_tool +from langchain_core.utils.function_calling import ( + convert_to_json_schema, + convert_to_openai_tool, +) from langchain_core.utils.pydantic import is_basemodel_subclass from langchain_core.utils.utils import _build_model_kwargs from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator @@ -96,6 +104,8 @@ class AnthropicTool(TypedDict): description: NotRequired[str] + strict: NotRequired[bool] + cache_control: NotRequired[dict[str, str]] @@ -1722,6 +1732,24 @@ class ChatAnthropic(BaseChatModel): } if self.thinking is not None: payload["thinking"] = self.thinking + + if "response_format" in payload: + response_format = payload.pop("response_format") + if ( + isinstance(response_format, dict) + and response_format.get("type") == "json_schema" + and "schema" in response_format.get("json_schema", {}) + ): + # compat with langchain.agents.create_agent response_format, which is + # an approximation of OpenAI format + response_format = cast(dict, response_format["json_schema"]["schema"]) + payload["output_format"] = _convert_to_anthropic_output_format( + response_format + ) + + if "output_format" in payload and not payload["betas"]: + payload["betas"] = ["structured-outputs-2025-11-13"] + return {k: v for k, v in payload.items() if v is not None} def _create(self, payload: dict) -> Any: @@ -1918,6 +1946,7 @@ class ChatAnthropic(BaseChatModel): *, tool_choice: dict[str, str] | str | None = None, parallel_tool_calls: bool | None = None, + strict: bool | None = None, **kwargs: Any, ) -> Runnable[LanguageModelInput, AIMessage]: r"""Bind tool-like objects to this chat model. @@ -1935,6 +1964,8 @@ class ChatAnthropic(BaseChatModel): Defaults to `None` (no specification, which allows parallel tool use). !!! version-added "Added in `langchain-anthropic` 0.3.2" + strict: If `True`, Claude's schema adherence is applied to tool calls. + See: [Anthropic docs](https://docs.claude.com/en/docs/build-with-claude/structured-outputs#when-to-use-json-outputs-vs-strict-tool-use). kwargs: Any additional parameters are passed directly to `bind`. Example: @@ -2148,7 +2179,9 @@ class ChatAnthropic(BaseChatModel): ``` """ # noqa: E501 formatted_tools = [ - tool if _is_builtin_tool(tool) else convert_to_anthropic_tool(tool) + tool + if _is_builtin_tool(tool) + else convert_to_anthropic_tool(tool, strict=strict) for tool in tools ] if not tool_choice: @@ -2187,6 +2220,7 @@ class ChatAnthropic(BaseChatModel): schema: dict | type, *, include_raw: bool = False, + method: Literal["function_calling", "json_schema"] = "function_calling", **kwargs: Any, ) -> Runnable[LanguageModelInput, dict | BaseModel]: """Model wrapper that returns outputs formatted to match the given schema. @@ -2221,6 +2255,14 @@ class ChatAnthropic(BaseChatModel): The final output is always a `dict` with keys `'raw'`, `'parsed'`, and `'parsing_error'`. + method: The structured output method to use. Options are: + + - `'function_calling'` (default): Use forced tool calling to get + structured output. + - `'json_schema'`: Use Claude's dedicated + [structured output](https://docs.claude.com/en/docs/build-with-claude/structured-outputs) + feature. + kwargs: Additional keyword arguments are ignored. Returns: @@ -2314,33 +2356,60 @@ class ChatAnthropic(BaseChatModel): # } ``` """ # noqa: E501 - formatted_tool = convert_to_anthropic_tool(schema) - tool_name = formatted_tool["name"] - if self.thinking is not None and self.thinking.get("type") == "enabled": - llm = self._get_llm_for_structured_output_when_thinking_is_enabled( - schema, - formatted_tool, + if method == "json_mode": + warning_message = ( + "Unrecognized structured output method 'json_mode'. Defaulting to " + "'json_schema' method." ) - else: - llm = self.bind_tools( - [schema], - tool_choice=tool_name, + warnings.warn(warning_message, stacklevel=2) + method = "json_schema" + + if method == "function_calling": + formatted_tool = convert_to_anthropic_tool(schema) + tool_name = formatted_tool["name"] + if self.thinking is not None and self.thinking.get("type") == "enabled": + llm = self._get_llm_for_structured_output_when_thinking_is_enabled( + schema, + formatted_tool, + ) + else: + llm = self.bind_tools( + [schema], + tool_choice=tool_name, + ls_structured_output_format={ + "kwargs": {"method": "function_calling"}, + "schema": formatted_tool, + }, + ) + + if isinstance(schema, type) and is_basemodel_subclass(schema): + output_parser: OutputParserLike = PydanticToolsParser( + tools=[schema], + first_tool_only=True, + ) + else: + output_parser = JsonOutputKeyToolsParser( + key_name=tool_name, + first_tool_only=True, + ) + elif method == "json_schema": + llm = self.bind( + output_format=_convert_to_anthropic_output_format(schema), ls_structured_output_format={ - "kwargs": {"method": "function_calling"}, - "schema": formatted_tool, + "kwargs": {"method": "json_schema"}, + "schema": convert_to_openai_tool(schema), }, ) - - if isinstance(schema, type) and is_basemodel_subclass(schema): - output_parser: OutputParserLike = PydanticToolsParser( - tools=[schema], - first_tool_only=True, - ) + if isinstance(schema, type) and is_basemodel_subclass(schema): + output_parser = PydanticOutputParser(pydantic_object=schema) + else: + output_parser = JsonOutputParser() else: - output_parser = JsonOutputKeyToolsParser( - key_name=tool_name, - first_tool_only=True, + error_message = ( + f"Unrecognized structured output method '{method}'. " + f"Expected 'function_calling' or 'json_schema'." ) + raise ValueError(error_message) if include_raw: parser_assign = RunnablePassthrough.assign( @@ -2447,6 +2516,8 @@ class ChatAnthropic(BaseChatModel): def convert_to_anthropic_tool( tool: dict[str, Any] | type | Callable | BaseTool, + *, + strict: bool | None = None, ) -> AnthropicTool: """Convert a tool-like object to an Anthropic tool definition.""" # already in Anthropic tool format @@ -2455,13 +2526,15 @@ def convert_to_anthropic_tool( ): anthropic_formatted = AnthropicTool(tool) # type: ignore[misc] else: - oai_formatted = convert_to_openai_tool(tool)["function"] + oai_formatted = convert_to_openai_tool(tool, strict=strict)["function"] anthropic_formatted = AnthropicTool( name=oai_formatted["name"], input_schema=oai_formatted["parameters"], ) if "description" in oai_formatted: anthropic_formatted["description"] = oai_formatted["description"] + if "strict" in oai_formatted and isinstance(strict, bool): + anthropic_formatted["strict"] = oai_formatted["strict"] return anthropic_formatted @@ -2511,6 +2584,22 @@ def _lc_tool_calls_to_anthropic_tool_use_blocks( ] +def _convert_to_anthropic_output_format(schema: dict | type) -> dict[str, Any]: + """Convert JSON schema, Pydantic model, or TypedDict into Claude output_format. + + See: https://docs.claude.com/en/docs/build-with-claude/structured-outputs + """ + from anthropic import transform_schema + + is_pydantic_class = isinstance(schema, type) and is_basemodel_subclass(schema) + if is_pydantic_class or isinstance(schema, dict): + json_schema = transform_schema(schema) + else: + # TypedDict + json_schema = transform_schema(convert_to_json_schema(schema)) + return {"type": "json_schema", "schema": json_schema} + + def _make_message_chunk_from_anthropic_event( event: anthropic.types.RawMessageStreamEvent, *, diff --git a/libs/partners/anthropic/pyproject.toml b/libs/partners/anthropic/pyproject.toml index 9c18fa86945..d74ed40b081 100644 --- a/libs/partners/anthropic/pyproject.toml +++ b/libs/partners/anthropic/pyproject.toml @@ -12,7 +12,7 @@ authors = [] version = "1.0.3" requires-python = ">=3.10.0,<4.0.0" dependencies = [ - "anthropic>=0.69.0,<1.0.0", + "anthropic>=0.73.0,<1.0.0", "langchain-core>=1.0.4,<2.0.0", "pydantic>=2.7.4,<3.0.0", ] diff --git a/libs/partners/anthropic/tests/cassettes/test_strict_tool_use.yaml.gz b/libs/partners/anthropic/tests/cassettes/test_strict_tool_use.yaml.gz new file mode 100644 index 00000000000..8d9cb98abb0 Binary files /dev/null and b/libs/partners/anthropic/tests/cassettes/test_strict_tool_use.yaml.gz differ 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 bcc5d31390d..0201233dfd4 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py @@ -12,6 +12,8 @@ import httpx import pytest import requests from anthropic import BadRequestError +from langchain.agents import create_agent +from langchain.agents.structured_output import ProviderStrategy from langchain_core.callbacks import CallbackManager from langchain_core.exceptions import OutputParserException from langchain_core.messages import ( @@ -27,6 +29,7 @@ from langchain_core.outputs import ChatGeneration, LLMResult from langchain_core.prompts import ChatPromptTemplate from langchain_core.tools import tool from pydantic import BaseModel, Field +from typing_extensions import TypedDict from langchain_anthropic import ChatAnthropic from langchain_anthropic._compat import _convert_from_v1_to_anthropic @@ -663,6 +666,90 @@ def test_with_structured_output() -> None: assert response["location"] +class Person(BaseModel): + """Person data.""" + + name: str + age: int + nicknames: list[str] | None + + +class PersonDict(TypedDict): + """Person data as a TypedDict.""" + + name: str + age: int + nicknames: list[str] | None + + +@pytest.mark.parametrize("schema", [Person, Person.model_json_schema(), PersonDict]) +def test_response_format(schema: dict | type) -> None: + model = ChatAnthropic( + model="claude-sonnet-4-5", # type: ignore[call-arg] + betas=["structured-outputs-2025-11-13"], + ) + query = "Chester (a.k.a. Chet) is 100 years old." + + response = model.invoke(query, response_format=schema) + parsed = json.loads(response.text) + if isinstance(schema, type) and issubclass(schema, BaseModel): + schema.model_validate(parsed) + else: + assert isinstance(parsed, dict) + assert parsed["name"] + assert parsed["age"] + + +def test_response_format_in_agent() -> None: + class Weather(BaseModel): + temperature: float + units: str + + # no tools + agent = create_agent( + "anthropic:claude-sonnet-4-5", response_format=ProviderStrategy(Weather) + ) + result = agent.invoke({"messages": [{"role": "user", "content": "75 degrees F."}]}) + assert len(result["messages"]) == 2 + parsed = json.loads(result["messages"][-1].text) + assert Weather(**parsed) == result["structured_response"] + + # with tools + def get_weather(location: str) -> str: + """Get the weather at a location.""" + return "75 degrees Fahrenheit." + + agent = create_agent( + "anthropic:claude-sonnet-4-5", + tools=[get_weather], + response_format=ProviderStrategy(Weather), + ) + result = agent.invoke( + {"messages": [{"role": "user", "content": "What's the weather in SF?"}]}, + ) + assert len(result["messages"]) == 4 + assert result["messages"][1].tool_calls + parsed = json.loads(result["messages"][-1].text) + assert Weather(**parsed) == result["structured_response"] + + +@pytest.mark.vcr +def test_strict_tool_use() -> None: + model = ChatAnthropic( + model="claude-sonnet-4-5", # type: ignore[call-arg] + betas=["structured-outputs-2025-11-13"], + ) + + def get_weather(location: str, unit: Literal["C", "F"]) -> str: + """Get the weather at a location.""" + return "75 degrees Fahrenheit." + + model_with_tools = model.bind_tools([get_weather], strict=True) + + response = model_with_tools.invoke("What's the weather in Boston, in Celsius?") + assert response.tool_calls + + def test_get_num_tokens_from_messages() -> None: llm = ChatAnthropic(model=MODEL_NAME) # type: ignore[call-arg] diff --git a/libs/partners/anthropic/tests/integration_tests/test_standard.py b/libs/partners/anthropic/tests/integration_tests/test_standard.py index 91b2a86ac0a..95256eb6b34 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_standard.py +++ b/libs/partners/anthropic/tests/integration_tests/test_standard.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Literal, cast +import pytest from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage, BaseMessageChunk from langchain_tests.integration_tests import ChatModelIntegrationTests @@ -155,3 +156,31 @@ def _invoke(llm: ChatAnthropic, input_: list, stream: bool) -> AIMessage: # noq full = cast("BaseMessageChunk", chunk) if full is None else full + chunk return cast("AIMessage", full) return cast("AIMessage", llm.invoke(input_)) + + +class NativeStructuredOutputTests(TestAnthropicStandard): + @property + def chat_model_params(self) -> dict: + return {"model": "claude-sonnet-4-5"} + + @property + def structured_output_kwargs(self) -> dict: + return {"method": "json_schema"} + + +@pytest.mark.parametrize("schema_type", ["pydantic", "typeddict", "json_schema"]) +def test_native_structured_output( + schema_type: Literal["pydantic", "typeddict", "json_schema"], +) -> None: + test_instance = NativeStructuredOutputTests() + model = test_instance.chat_model_class(**test_instance.chat_model_params) + NativeStructuredOutputTests().test_structured_output(model, schema_type) + + +@pytest.mark.parametrize("schema_type", ["pydantic", "typeddict", "json_schema"]) +async def test_native_structured_output_async( + schema_type: Literal["pydantic", "typeddict", "json_schema"], +) -> None: + test_instance = NativeStructuredOutputTests() + model = test_instance.chat_model_class(**test_instance.chat_model_params) + await NativeStructuredOutputTests().test_structured_output_async(model, schema_type) 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 88f1ab5644a..dd930e82ce9 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -1581,3 +1581,19 @@ def test_streaming_cache_token_reporting() -> None: assert delta_chunk.usage_metadata["input_tokens"] == 135 assert delta_chunk.usage_metadata["output_tokens"] == 50 assert delta_chunk.usage_metadata["total_tokens"] == 185 + + +def test_strict_tool_use() -> None: + model = ChatAnthropic( + model="claude-sonnet-4-5", # type: ignore[call-arg] + betas=["structured-outputs-2025-11-13"], + ) + + def get_weather(location: str, unit: Literal["C", "F"]) -> str: + """Get the weather at a location.""" + return "75 degrees Fahrenheit." + + model_with_tools = model.bind_tools([get_weather], strict=True) + + tool_definition = model_with_tools.kwargs["tools"][0] # type: ignore[attr-defined] + assert tool_definition["strict"] is True diff --git a/libs/partners/anthropic/uv.lock b/libs/partners/anthropic/uv.lock index fc3ca241673..d3af5bd827b 100644 --- a/libs/partners/anthropic/uv.lock +++ b/libs/partners/anthropic/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.72.1" +version = "0.73.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/dd/f3/feb750a21461090ecf48bbebcaa261cd09003cc1d14e2fa9643ad59edd4d/anthropic-0.72.1.tar.gz", hash = "sha256:a6d1d660e1f4af91dddc732f340786d19acaffa1ae8e69442e56be5fa6539d51", size = 415395, upload-time = "2025-11-11T16:53:29.001Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/07/f550112c3f5299d02f06580577f602e8a112b1988ad7c98ac1a8f7292d7e/anthropic-0.73.0.tar.gz", hash = "sha256:30f0d7d86390165f86af6ca7c3041f8720bb2e1b0e12a44525c8edfdbd2c5239", size = 425168, upload-time = "2025-11-14T18:47:52.635Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/05/d9d45edad1aa28330cea09a3b35e1590f7279f91bb5ab5237c70a0884ea3/anthropic-0.72.1-py3-none-any.whl", hash = "sha256:81e73cca55e8924776c8c4418003defe6bf9eaf0cd92beb94c8dbf537b95316f", size = 357373, upload-time = "2025-11-11T16:53:27.438Z" }, + { url = "https://files.pythonhosted.org/packages/15/b1/5d4d3f649e151e58dc938cf19c4d0cd19fca9a986879f30fea08a7b17138/anthropic-0.73.0-py3-none-any.whl", hash = "sha256:0d56cd8b3ca3fea9c9b5162868bdfd053fbc189b8b56d4290bd2d427b56db769", size = 367839, upload-time = "2025-11-14T18:47:51.195Z" }, ] [[package]] @@ -586,7 +586,7 @@ typing = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.69.0,<1.0.0" }, + { name = "anthropic", specifier = ">=0.73.0,<1.0.0" }, { name = "langchain-core", editable = "../../core" }, { name = "pydantic", specifier = ">=2.7.4,<3.0.0" }, ] @@ -624,7 +624,7 @@ typing = [ [[package]] name = "langchain-core" -version = "1.0.4" +version = "1.0.5" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/standard-tests/langchain_tests/unit_tests/chat_models.py b/libs/standard-tests/langchain_tests/unit_tests/chat_models.py index 105f868c90b..d9ba3a7f8b3 100644 --- a/libs/standard-tests/langchain_tests/unit_tests/chat_models.py +++ b/libs/standard-tests/langchain_tests/unit_tests/chat_models.py @@ -13,13 +13,9 @@ from langchain_core.language_models import BaseChatModel from langchain_core.load import dumpd, load from langchain_core.runnables import RunnableBinding from langchain_core.tools import BaseTool, tool -from pydantic import BaseModel, Field, SecretStr -from pydantic.v1 import BaseModel as BaseModelV1 -from pydantic.v1 import Field as FieldV1 -from pydantic.v1 import ValidationError as ValidationErrorV1 +from pydantic import BaseModel, Field, SecretStr, ValidationError from langchain_tests.base import BaseStandardTests -from langchain_tests.utils.pydantic import PYDANTIC_MAJOR_VERSION if TYPE_CHECKING: from pytest_benchmark.fixture import ( # type: ignore[import-untyped] @@ -28,21 +24,6 @@ if TYPE_CHECKING: from syrupy.assertion import SnapshotAssertion -def generate_schema_pydantic_v1_from_2() -> Any: - """Use to generate a schema from v1 namespace in pydantic 2.""" - if PYDANTIC_MAJOR_VERSION != 2: - msg = "This function is only compatible with Pydantic v2." - raise AssertionError(msg) - - class PersonB(BaseModelV1): - """Record attributes of a person.""" - - name: str = FieldV1(..., description="The name of the person.") - age: int = FieldV1(..., description="The age of the person.") - - return PersonB - - def generate_schema_pydantic() -> Any: """Works with either pydantic 1 or 2.""" @@ -57,9 +38,6 @@ def generate_schema_pydantic() -> Any: TEST_PYDANTIC_MODELS = [generate_schema_pydantic()] -if PYDANTIC_MAJOR_VERSION == 2: - TEST_PYDANTIC_MODELS.append(generate_schema_pydantic_v1_from_2()) - class ChatModelTests(BaseStandardTests): """Base class for chat model tests.""" @@ -1023,18 +1001,18 @@ class ChatModelUnitTests(ChatModelTests): (e.g., `ChatProviderName`). """ - class ExpectedParams(BaseModelV1): + class ExpectedParams(BaseModel): ls_provider: str ls_model_name: str ls_model_type: Literal["chat"] - ls_temperature: float | None - ls_max_tokens: int | None - ls_stop: list[str] | None + ls_temperature: float | None = None + ls_max_tokens: int | None = None + ls_stop: list[str] | None = None ls_params = model._get_ls_params() try: - ExpectedParams(**ls_params) # type: ignore[arg-type] - except ValidationErrorV1 as e: + ExpectedParams(**ls_params) + except ValidationError as e: pytest.fail(f"Validation error: {e}") # Test optional params @@ -1045,8 +1023,8 @@ class ChatModelUnitTests(ChatModelTests): ) ls_params = model._get_ls_params() try: - ExpectedParams(**ls_params) # type: ignore[arg-type] - except ValidationErrorV1 as e: + ExpectedParams(**ls_params) + except ValidationError as e: pytest.fail(f"Validation error: {e}") def test_serdes(self, model: BaseChatModel, snapshot: SnapshotAssertion) -> None: