From d30e2b88db411689ac9ba19e190b8addada76f72 Mon Sep 17 00:00:00 2001 From: ccurme Date: Fri, 6 Feb 2026 11:11:01 -0500 Subject: [PATCH] fix(anthropic): support output_config (#35036) --- .../langchain_anthropic/chat_models.py | 57 ++++----- .../test_response_format_in_agent.yaml.gz | Bin 2046 -> 2053 bytes .../cassettes/test_strict_tool_use.yaml.gz | Bin 1277 -> 1289 bytes .../integration_tests/test_chat_models.py | 2 - .../tests/unit_tests/test_chat_models.py | 108 ++++-------------- 5 files changed, 45 insertions(+), 122 deletions(-) diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index 8c4fdfce5cb..a23a05463e7 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -1167,22 +1167,22 @@ class ChatAnthropic(BaseChatModel): and "schema" in response_format.get("json_schema", {}) ): response_format = cast(dict, response_format["json_schema"]["schema"]) - # Convert OpenAI-style response_format to Anthropic's output_format - payload["output_format"] = _convert_to_anthropic_output_format( + # Convert OpenAI-style response_format to Anthropic's output_config.format + output_config = payload.setdefault("output_config", {}) + output_config["format"] = _convert_to_anthropic_output_config_format( response_format ) + # Handle deprecated output_format parameter for backward compatibility if "output_format" in payload: - # Native structured output requires the structured outputs beta - if payload["betas"]: - if "structured-outputs-2025-11-13" not in payload["betas"]: - # Merge with existing betas - payload["betas"] = [ - *payload["betas"], - "structured-outputs-2025-11-13", - ] - else: - payload["betas"] = ["structured-outputs-2025-11-13"] + warnings.warn( + "The 'output_format' parameter is deprecated and will be removed in a " + "future version. Use 'output_config={\"format\": ...}' instead.", + DeprecationWarning, + stacklevel=2, + ) + output_config = payload.setdefault("output_config", {}) + output_config["format"] = payload.pop("output_format") if self.reuse_last_container: # Check for most recent AIMessage with container set in response_metadata @@ -1197,24 +1197,9 @@ class ChatAnthropic(BaseChatModel): payload["container"] = container_id break - # Check if any tools have strict mode enabled + # Note: Beta headers are no longer required for structured outputs + # (output_config.format or strict tool use) as they are now generally available if "tools" in payload and isinstance(payload["tools"], list): - has_strict_tool = any( - isinstance(tool, dict) and tool.get("strict") is True - for tool in payload["tools"] - ) - if has_strict_tool: - # Strict tool use requires the structured outputs beta - if payload["betas"]: - if "structured-outputs-2025-11-13" not in payload["betas"]: - # Merge with existing betas - payload["betas"] = [ - *payload["betas"], - "structured-outputs-2025-11-13", - ] - else: - payload["betas"] = ["structured-outputs-2025-11-13"] - # Auto-append required betas for specific tool types and input_examples has_input_examples = False for tool in payload["tools"]: @@ -1684,7 +1669,9 @@ class ChatAnthropic(BaseChatModel): ) elif method == "json_schema": llm = self.bind( - output_format=_convert_to_anthropic_output_format(schema), + output_config={ + "format": _convert_to_anthropic_output_config_format(schema) + }, ls_structured_output_format={ "kwargs": {"method": "json_schema"}, "schema": convert_to_openai_tool(schema), @@ -1911,10 +1898,16 @@ 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`. +def _convert_to_anthropic_output_config_format(schema: dict | type) -> dict[str, Any]: + """Convert JSON schema, Pydantic model, or `TypedDict` into `output_config.format`. See Claude docs on [structured outputs](https://platform.claude.com/docs/en/build-with-claude/structured-outputs). + + Args: + schema: A JSON schema dict, Pydantic model class, or TypedDict. + + Returns: + A dict with `type` and `schema` keys suitable for `output_config.format`. """ from anthropic import transform_schema diff --git a/libs/partners/anthropic/tests/cassettes/test_response_format_in_agent.yaml.gz b/libs/partners/anthropic/tests/cassettes/test_response_format_in_agent.yaml.gz index db85f4f3411a3391254aa194ee1a11b13fb213bd..24576faa70ef5ce8113742c15e28af8143845923 100644 GIT binary patch literal 2053 zcmV+g2>SOQiwFR%6NPC4|Ls{@kD^E$zTaQbd$q|V>;Sg2LnnLD+Ct;DIHGd8gDMoX zI1Jzcvp@cO3(%(hXxp)SC!39vbb|2KQ*Tw_b8r&(`^rtT^zIkt#))u#ck}D7jvv}d ze)IP)H}KER`Y`^})c@f}^;AzlnEW8e=-_J2M7+}e_~6efT{TMz3hc`}r> z6SN8Lvh34j>OXb+Yiy_+_?uDS0?-ObC@u_)kZQ^K5lA&)X3%{U zmzG3WTNhTQz8#@J*jPp%K5KAzaRB#r7EFQyZHsl1mw@^RPVoPi0ei(c0bxo;q+j2-*T(#=oSq=zmNH%oE`MXQs?SRX-~j#jT%2X5v?`0nQ0a6EyJtHi&%@v>Kq-hMx<=64hYpV!>y08nAG)1Sw>1V^SZ3Q^u+ZD~5k2R`e>j^CN6i?3(1Z~=Hs z#qVL^0`QoM-^0QM;4u}yhlLBkV=8_R3zr=pQSn7A+)8~X5VM-lV1h-VW$7ZEbon~i zdb>C%#4C}db6#;!4g2Rv!U86Z9saiAP!7xP3$uo<>w^ute@BN|?jb$Va}=uBLzO_s zxovccTDr$7ud(u@8d>8`t5U9NnUcuLQg2@CVnvsZT54|V3Jbt23N1_ndG4aDR!a=M$Tx<^uIwv~=x&<1~pXnQx zb%vcNtwckzyVOpUd)ScdF0~T{U{kWY)J~kx)ywR}{>2VH%1)Gf2%h4j>_iC&w&0`e z#LJle0d}HtPo37)PF(H8)lU3mJCV3)9EGX-{3~QJN17${+i~nNN}u8IDsiY>1xjif z+R_?xJCOR7WsEYV%W_W>c{5l{H^YhCE;(yNbu~jg22t*{R4HDKKr$*wD-aiEOL>|u zvmw?*GM%a(9rnyR906zHS*20PK_Ls0HVtKG8wzWJ!`Mr31^J3<6Zf$&Cah(6EQN5n z%@IXw2=337-87ZOQaJXIk4o{#==LS}6R3Nj$_H*^V}Qn+R9dMU0gbmrfyG9D&su9F z3P6J*2VQ7FTd5$OP)i-~XV@qgM=5&G?=>Hn4<7D;K^2dRtN|=q5GAmv!IKqwq;5%F zSCBTZt*Dkz+h_9P0{E2L6c0KoO%!zI@ELJJkcmYzY^nX)W)BJb1Dx(T;#bbmUW3s# zwYsg{I9*4mAnT5!w$QCYwFvAC4#)yL5tWgg9#i2d1Jy&psI-HDMW~lDd2P0TiFj>I zOPxSq4PN9;ousDTPpvUX-006gkA& zj336UjLD*<8>UQg>$0JV8vxP0wvaQ1r|$VP1C3tX^~f(R$sciX2o>Fb(Ax!7{TV$ zuebkk=sVp2WY70Jb~CFL;1;( z-D{kV#y6*>vBxW{AeG60?iWNuE2e4*-O$8bBtUpUmZs5PRm~s;E~*y(;QeQUAq?y0d!Q0BiA{vfYW&a|zz5Ei9wyb~=6_ zi+^{}G<6STupL=CVJ``t`*2jto@a*6+rh5am<}t*diH#8=-eBgTK0Tz=-g{nEqlHi jI`i{F=N___J+GnjDv?)-yh`N9OXU9mQYBnj94Y_+3l{XV literal 2046 zcmV|Ls}tbE3!={l0%i_S05Pl{h}1-5e?ro)5*@AV!OWld zBOVJ{F8OQU5ZakD&@!hdeJKE?1?HuJLd@ofK;nbqU;A^7OQe8bnFB3>1t4kTIl4mb zki4>k7IAtUcc;$$yWVK!AY~0db2Dh?1)!~qoV18wb)|*N)gLjeikkAm8fd9;FC`M) z|NaF!;#|}vY$011J({}Au}Yl*;Yz>~1epK~NktoYI9g%&9fAnXOAu4%*CrZy&QQzE zLtqMv1zK6*4!r`NJx?69p)KAH2)6^?f#9Z+TY8Ju^9w)#(aMBTdwvg_tSd_kDUmI- zJX`GMcC$+`Riffx0@JgHGJ!>M6N$WqVBg^|^s0jbu+|3bm6ie6Rw8@>4ky2|hs6my z^Xd2Rkl?wNQv%jjKOCeNt}wH$C;X|LM~owSb08ap?H;;k8JbdT|)} zxM91gbyDC8>hMGQN-Pd3b#eM;Q0mgOL0tNfdM5;?dE~wl=7q^?TaqPi+^`-1=#`*N z!;1XI(#>CuNm9$F#7&Zh6=Uzz&?Kk$#8_gzdS>;|FgY{;p z8{1aPfKWeh-xvMPs#r>xyVIJgTvdFfWuK_2iTt@xEgdsFw!C%XiEsT zpCuFae4o5Bf`}N+Dbsb&94HZN*{VF{Kul%_r)a`waBuYj@W`iY^G{~oES)3n)mz` z2sJa&G?jZCf2T_8@nW`~s8WlOv87KLn9g8?5pOFI(7&aVPGu;5*Jl9>*YDp0T!; zGOB~Iga_Wl@cDzhoh6b8&-7NbgB;4mBh(uW;HS-o41n4K>8}xt0tQoH#)dEom|~lA z&?qbPu&J!D*xoWRfstvk*FP9bsFv6;#yO~LZbB2-t5%`cwQ(;W_isQhWX}*hf&@7) zQPj2|rOZ!OkF!*||@j-h1Y2Y%Y{X_SGWgiDtK+p>?&OO4$CIO(NEPDoGO(^m$@ zJG<)Vx$iIgF#kPupH=YLmA+Xbo;R?CJ(uZ8nkAhRW|Lzfarr-EFWtIQIIVg5!mGe^ z1Vgp#jflcHZ6q!Rol5i0aq)BMD$E;M1bMC%KyYw^raiR*++U%ui<lH0P!UqAs_>wW_H8{@9dE|9 zHn>mZNnczmfudz|3y8HX6?b}(sn)~~x?b~_Wn)`dv c_T?AbchR_u#$7b-XKLL40m72u3wQ!-&jQ4$80*|Kfgq0`li7{HR}XEx z6_!kRnAewnS!LvmlL-GF`i34J<6#;n1WDUZv2T)>5NU&ptC;MpBE>u{BoyaYALC_( z5FO_qqa)12IB3^iC$l+R<|%2{Le7({{qO+Vew?TIf#T?4TYrdv6_%adr8rAsmf?4Y zE_-A=6>rC{Zhi3UON*>>$CtUf8?P6;$y{v;tMII;c;h)zg5|hB=v%a#>d%IGr1Uq< zsiUC!d|Q2s0>r<*jrh$JY0rt;>$pluuD`q&y{p z)yUpyn6ZV@Wnk;{LKTETD+|m&cjNhByim-UWodhF(0Mc!z3$E3@JKn`=sh=j77B95 zs+S5}McDe;zQlKl&$@Nd8Wwe7W6*x1ZBfGzpR*&S%FNueOBH>HpOy`FDIjRjDqL%@ zKRRWp&~BJ}m;$^T;;YeJLjJvh3FZzgWfC>(r(F(r00FbL0(!`kQ}`HLPcd^;s89NQ z(*UJw$I@fhY^@;%4k~HupnlMTmYkj_85sJy139LLjuKM4p;0jZrDrvFfylb|PI@YmEf$h5NEHsg?)rUG)Vm6fQX+T%Zl9T4J4!Ev_Hv_Yr!zX z2wq!WNUt#^sAzv0B2|FC$E&}X;S_wV@~e63v1|#sa?ZqR7d2u(=9x72ssZnY8K*0c zk-aBImPqgkIt9s;M1)I(B{`RPyumD!%3EJ6O>qRi`OzRZlxO(%&fv;lR{h`F-phledBu=@M9bdY3 zbpT_wjSDGHpbmQxpwlfuxJ=`Uilg+S)&L-iESFcuv~AZWf8YHJeSDq)5)A+V7O;Sx literal 1277 zcmVnK|D9G{bD~HTeb29OpQb8ROb}vrwQ6cBe&a^mpz`$w-L14X z4Fin^Yk&OSMorvVlW2SrIQRCsr|EOZ$j^f0B5&O&wJ^bDtM=P(A>~0<*8aJv!H<+9 z;knTQ51N_pGvP{7(+Ly58+^102Xi{1qdL;;(x6-FY0Ux; z>;iX;hpM5$aN@bo9F=V~7|fSgvm-o+7^3Yor0q_qn`)@t=f;2)M$cs7&`hyAzB70o zKXpbT)GQW4Gb1+hs-frFYkykbg<7NV-DhET)Vf?57;nPifmz&$LZ@N|Y^}xHqDF4S ztUyCsb#-%9H4Fx}@TSHCjJ`#2bL+b!HGol#RSJz>HlEgp>t<11bE|>&Bk%BVl!h^) zizzJbG}70Rp%DwYEubJY)?*4|3=F2`XNz5Vf5eb$rzp19Rn5twdkyJvg^|=mC2pI+ z#?Vk3{q!AEt%P^1b>Lbl$P$J9b{XnzhPahhn-%*J=*lG1s-Zuycb+~<(NGrLOGZP` zaw#=3hM0M-mH4i)gpFHz({>(eCg{gDA-$K=RO~(dnNGM>Rt>RZH(gi#0T&SoFv;Kz ztF%Rkq+wR+vh&aBtHz5R9qPzXl}rhNGkl8Y!yQi_z2h~OgN26)hfe}F{^ z=LCsQL4t6hFv6E2x&SE3lzae-lKdk{ikxIhu!Ke*fL@d!rOKL=A7XM5P|ir6D?x^$ zOQCtm(S=wRe7I+)&?LVQmT!+4rl`Kh$_Zh`R zL){a9L!uRf~j3^VZ!RZ1DHjclkR?;=4`#E{3ikPX4Dn1V2PpkeiI;X~J{z zX6n^yxya;!`qkj~7Jurh=M42}W^Bi+`F1ih8q!??=UJMBKpI|*yFJ}kZ4W%MIJ877 zEbz{Lns)vBaJJS>y;qqM9oeZJ7ER+(af>^$96oo1zuYe&+*e#tM|wCcJDc^_YW=S>*!%VK$1b> zIrWNxugN5Mbv*ej4SBmRmj!hU|02;$+`(y>d=d0CiF{k=97{D>Pn(OKMUBtc8vR{uBX?osLmN#>`XWy0Np^Ge=sE(n8Vpevn=5G0&Kl^ z%(-(YSFw^X5xFu8&I8_(EyCwvL+nNTi*IwX+~nO zxc3IRpgbp+uM7tIW!C?#y`J{3_1Eyu4jw<5xbC81=?;Vf*2SczB@0?kgp9t8>qnjp~P;!DGpGDa^ANquEl2R#Bs3SoP n=xjp~slEWtR2)V-%z^=;scLm~NcdK5^4HD3o+7}wuMGeI8J~zO 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 7fb53d4f5ab..b92f3a54aa0 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py @@ -727,7 +727,6 @@ class PersonDict(TypedDict): 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." @@ -779,7 +778,6 @@ def test_response_format_in_agent() -> None: 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: 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 ca252e25013..42bb97e2944 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -1663,7 +1663,6 @@ def test_streaming_cache_token_reporting() -> None: def test_strict_tool_use() -> None: model = ChatAnthropic( model=MODEL_NAME, # type: ignore[call-arg] - betas=["structured-outputs-2025-11-13"], ) def get_weather(location: str, unit: Literal["C", "F"]) -> str: @@ -1676,8 +1675,8 @@ def test_strict_tool_use() -> None: assert tool_definition["strict"] is True -def test_beta_merging_with_response_format() -> None: - """Test that structured-outputs beta is merged with existing betas.""" +def test_response_format_with_output_config() -> None: + """Test that response_format is converted to output_config.format.""" class Person(BaseModel): """Person data.""" @@ -1685,114 +1684,47 @@ def test_beta_merging_with_response_format() -> None: name: str age: int - # Auto-inject structured-outputs beta with no others specified + # Test that response_format converts to output_config.format model = ChatAnthropic(model=MODEL_NAME) payload = model._get_request_payload( "Test query", response_format=Person.model_json_schema(), ) - assert payload["betas"] == ["structured-outputs-2025-11-13"] + assert "output_config" in payload + assert "format" in payload["output_config"] + assert payload["output_config"]["format"]["type"] == "json_schema" + assert "schema" in payload["output_config"]["format"] - # Merge structured-outputs beta if other betas are present - model = ChatAnthropic( - model=MODEL_NAME, - betas=["mcp-client-2025-04-04"], - ) - payload = model._get_request_payload( - "Test query", - response_format=Person.model_json_schema(), - ) - assert payload["betas"] == [ - "mcp-client-2025-04-04", - "structured-outputs-2025-11-13", - ] - - # Structured-outputs beta already present - don't duplicate - model = ChatAnthropic( - model=MODEL_NAME, - betas=[ - "mcp-client-2025-04-04", - "structured-outputs-2025-11-13", - ], - ) - payload = model._get_request_payload( - "Test query", - response_format=Person.model_json_schema(), - ) - assert payload["betas"] == [ - "mcp-client-2025-04-04", - "structured-outputs-2025-11-13", - ] - - # No response_format - betas should not be modified - model = ChatAnthropic( - model=MODEL_NAME, - betas=["mcp-client-2025-04-04"], - ) + # No response_format - output_config should not have format + model = ChatAnthropic(model=MODEL_NAME) payload = model._get_request_payload("Test query") - assert payload["betas"] == ["mcp-client-2025-04-04"] + if "output_config" in payload: + assert "format" not in payload["output_config"] -def test_beta_merging_with_strict_tool_use() -> None: - """Test beta merging for strict tools.""" +def test_strict_tool_use_payload() -> None: + """Test that strict tool use property is correctly passed through to payload.""" def get_weather(location: str) -> str: """Get the weather at a location.""" return "Sunny" - # Auto-inject structured-outputs beta with no others specified + # Test that strict=True is correctly passed to payload model = ChatAnthropic(model=MODEL_NAME) # type: ignore[call-arg] model_with_tools = model.bind_tools([get_weather], strict=True) payload = model_with_tools._get_request_payload( # type: ignore[attr-defined] "What's the weather?", **model_with_tools.kwargs, # type: ignore[attr-defined] ) - assert payload["betas"] == ["structured-outputs-2025-11-13"] + assert payload["tools"][0]["strict"] is True - # Merge structured-outputs beta if other betas are present - model = ChatAnthropic( - model=MODEL_NAME, # type: ignore[call-arg] - betas=["mcp-client-2025-04-04"], - ) - model_with_tools = model.bind_tools([get_weather], strict=True) - payload = model_with_tools._get_request_payload( # type: ignore[attr-defined] + # Test that strict=False is correctly passed to payload + model_without_strict = model.bind_tools([get_weather], strict=False) + payload = model_without_strict._get_request_payload( # type: ignore[attr-defined] "What's the weather?", - **model_with_tools.kwargs, # type: ignore[attr-defined] + **model_without_strict.kwargs, # type: ignore[attr-defined] ) - assert payload["betas"] == [ - "mcp-client-2025-04-04", - "structured-outputs-2025-11-13", - ] - - # Structured-outputs beta already present - don't duplicate - model = ChatAnthropic( - model=MODEL_NAME, # type: ignore[call-arg] - betas=[ - "mcp-client-2025-04-04", - "structured-outputs-2025-11-13", - ], - ) - model_with_tools = model.bind_tools([get_weather], strict=True) - payload = model_with_tools._get_request_payload( # type: ignore[attr-defined] - "What's the weather?", - **model_with_tools.kwargs, # type: ignore[attr-defined] - ) - assert payload["betas"] == [ - "mcp-client-2025-04-04", - "structured-outputs-2025-11-13", - ] - - # No strict tools - betas should not be modified - model = ChatAnthropic( - model=MODEL_NAME, # type: ignore[call-arg] - betas=["mcp-client-2025-04-04"], - ) - model_with_tools = model.bind_tools([get_weather], strict=False) - payload = model_with_tools._get_request_payload( # type: ignore[attr-defined] - "What's the weather?", - **model_with_tools.kwargs, # type: ignore[attr-defined] - ) - assert payload["betas"] == ["mcp-client-2025-04-04"] + assert payload["tools"][0].get("strict") is False def test_auto_append_betas_for_tool_types() -> None: