4747from ._turn import AssistantTurn , SystemTurn , Turn , UserTurn , user_turn
4848from ._utils import split_http_client_kwargs
4949
50+ # Models that support the new structured outputs API (output_format parameter)
51+ # https://platform.claude.com/docs/en/build-with-claude/structured-outputs
52+ STRUCTURED_OUTPUT_MODELS = {
53+ "claude-sonnet-4-5" ,
54+ "claude-opus-4-1" ,
55+ "claude-opus-4-5" ,
56+ "claude-haiku-4-5" ,
57+ }
58+
59+ STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-11-13"
60+
61+
62+ def _supports_structured_outputs (model : str ) -> bool :
63+ """Check if a model supports the new structured outputs API."""
64+ # Handle dated model versions like "claude-sonnet-4-5-20250514"
65+ # by checking if any supported model is a prefix
66+ for supported in STRUCTURED_OUTPUT_MODELS :
67+ if model .startswith (supported ):
68+ return True
69+ return False
70+
71+
5072if TYPE_CHECKING :
5173 from anthropic .types import (
5274 Message ,
@@ -353,8 +375,16 @@ def chat_perform(
353375 data_model : Optional [type [BaseModel ]] = None ,
354376 kwargs : Optional ["SubmitInputArgs" ] = None ,
355377 ):
356- kwargs = self ._chat_perform_args (stream , turns , tools , data_model , kwargs )
357- return self ._client .messages .create (** kwargs ) # type: ignore
378+ api_kwargs , use_beta = self ._chat_perform_args (
379+ stream , turns , tools , data_model , kwargs
380+ )
381+ if use_beta :
382+ # Beta API has slightly different type signatures but is runtime compatible
383+ return self ._client .beta .messages .create (
384+ betas = [STRUCTURED_OUTPUTS_BETA ],
385+ ** api_kwargs , # type: ignore[arg-type]
386+ )
387+ return self ._client .messages .create (** api_kwargs ) # type: ignore
358388
359389 @overload
360390 async def chat_perform_async (
@@ -387,8 +417,16 @@ async def chat_perform_async(
387417 data_model : Optional [type [BaseModel ]] = None ,
388418 kwargs : Optional ["SubmitInputArgs" ] = None ,
389419 ):
390- kwargs = self ._chat_perform_args (stream , turns , tools , data_model , kwargs )
391- return await self ._async_client .messages .create (** kwargs ) # type: ignore
420+ api_kwargs , use_beta = self ._chat_perform_args (
421+ stream , turns , tools , data_model , kwargs
422+ )
423+ if use_beta :
424+ # Beta API has slightly different type signatures but is runtime compatible
425+ return await self ._async_client .beta .messages .create (
426+ betas = [STRUCTURED_OUTPUTS_BETA ],
427+ ** api_kwargs , # type: ignore[arg-type]
428+ )
429+ return await self ._async_client .messages .create (** api_kwargs ) # type: ignore
392430
393431 def _chat_perform_args (
394432 self ,
@@ -397,44 +435,39 @@ def _chat_perform_args(
397435 tools : dict [str , Tool | ToolBuiltIn ],
398436 data_model : Optional [type [BaseModel ]] = None ,
399437 kwargs : Optional ["SubmitInputArgs" ] = None ,
400- ) -> "SubmitInputArgs" :
438+ ) -> tuple ["SubmitInputArgs" , bool ]:
439+ """
440+ Build kwargs for the Anthropic messages API.
441+
442+ Returns
443+ -------
444+ tuple
445+ A tuple of (kwargs, use_beta) where use_beta indicates whether
446+ to use the beta client for structured outputs.
447+ """
401448 tool_schemas = [self ._anthropic_tool_schema (tool ) for tool in tools .values ()]
402449
403- # If data extraction is requested, add a "mock" tool with parameters inferred from the data model
450+ use_beta = False
404451 data_model_tool : Tool | None = None
405- if data_model is not None :
406-
407- def _structured_tool_call (** kwargs : Any ):
408- """Extract structured data"""
409- pass
410-
411- data_model_tool = Tool .from_func (_structured_tool_call )
412-
413- data_model_schema = basemodel_to_param_schema (data_model )
414-
415- # Extract $defs from the nested schema and place at top level
416- # JSON Schema $ref pointers like "#/$defs/..." need $defs at the root
417- defs = data_model_schema .pop ("$defs" , None )
418-
419- params : dict [str , Any ] = {
420- "type" : "object" ,
421- "properties" : {
422- "data" : data_model_schema ,
423- },
424- }
425- if defs :
426- params ["$defs" ] = defs
427-
428- data_model_tool .schema ["function" ]["parameters" ] = params
429-
430- tool_schemas .append (self ._anthropic_tool_schema (data_model_tool ))
431452
432- if stream :
433- stream = False
434- warnings .warn (
435- "Anthropic does not support structured data extraction in streaming mode." ,
436- stacklevel = 2 ,
437- )
453+ if data_model is not None :
454+ # Check if model supports the new structured outputs API
455+ if _supports_structured_outputs (self .model ):
456+ # Use the new output_format API (supports streaming!)
457+ use_beta = True
458+ else :
459+ # Fall back to the old tool-based approach for older models
460+ data_model_tool = self ._create_data_model_tool (data_model )
461+ tool_schemas .append (self ._anthropic_tool_schema (data_model_tool ))
462+
463+ if stream :
464+ stream = False
465+ warnings .warn (
466+ "Anthropic does not support structured data extraction in "
467+ "streaming mode for older models. Consider using a newer model "
468+ f"like { ', ' .join (sorted (STRUCTURED_OUTPUT_MODELS ))} ." ,
469+ stacklevel = 4 ,
470+ )
438471
439472 kwargs_full : "SubmitInputArgs" = {
440473 "stream" : stream ,
@@ -445,7 +478,17 @@ def _structured_tool_call(**kwargs: Any):
445478 ** (kwargs or {}),
446479 }
447480
481+ if use_beta and data_model is not None :
482+ # Use the new output_format parameter for structured outputs
483+ from anthropic import transform_schema
484+
485+ kwargs_full ["output_format" ] = {
486+ "type" : "json_schema" ,
487+ "schema" : transform_schema (data_model ),
488+ }
489+
448490 if data_model_tool :
491+ # Old approach: force tool use
449492 kwargs_full ["tool_choice" ] = {
450493 "type" : "tool" ,
451494 "name" : data_model_tool .name ,
@@ -461,7 +504,35 @@ def _structured_tool_call(**kwargs: Any):
461504 sys_param ["cache_control" ] = self ._cache_control ()
462505 kwargs_full ["system" ] = [sys_param ]
463506
464- return kwargs_full
507+ return kwargs_full , use_beta
508+
509+ def _create_data_model_tool (self , data_model : type [BaseModel ]) -> Tool :
510+ """Create a fake tool for structured data extraction (old approach)."""
511+
512+ def _structured_tool_call (** kwargs : Any ):
513+ """Extract structured data"""
514+ pass
515+
516+ data_model_tool = Tool .from_func (_structured_tool_call )
517+
518+ data_model_schema = basemodel_to_param_schema (data_model )
519+
520+ # Extract $defs from the nested schema and place at top level
521+ # JSON Schema $ref pointers like "#/$defs/..." need $defs at the root
522+ defs = data_model_schema .pop ("$defs" , None )
523+
524+ params : dict [str , Any ] = {
525+ "type" : "object" ,
526+ "properties" : {
527+ "data" : data_model_schema ,
528+ },
529+ }
530+ if defs :
531+ params ["$defs" ] = defs
532+
533+ data_model_tool .schema ["function" ]["parameters" ] = params
534+
535+ return data_model_tool
465536
466537 def stream_text (self , chunk ) -> Optional [str ]:
467538 if chunk .type == "content_block_delta" :
@@ -576,7 +647,7 @@ def _token_count_args(
576647 ) -> dict [str , Any ]:
577648 turn = user_turn (* args )
578649
579- kwargs = self ._chat_perform_args (
650+ api_kwargs , _ = self ._chat_perform_args (
580651 stream = False ,
581652 turns = [turn ],
582653 tools = tools ,
@@ -591,7 +662,7 @@ def _token_count_args(
591662 "tool_choice" ,
592663 ]
593664
594- return {arg : kwargs [arg ] for arg in args_to_keep if arg in kwargs }
665+ return {arg : api_kwargs [arg ] for arg in args_to_keep if arg in api_kwargs }
595666
596667 def translate_model_params (self , params : StandardModelParams ) -> "SubmitInputArgs" :
597668 res : "SubmitInputArgs" = {}
@@ -753,11 +824,26 @@ def _anthropic_tool_schema(tool: "Tool | ToolBuiltIn") -> "ToolUnionParam":
753824
754825 def _as_turn (self , completion : Message , has_data_model = False ) -> AssistantTurn :
755826 contents = []
827+
828+ # Detect which structured output approach was used:
829+ # - Old approach: has a _structured_tool_call tool_use block
830+ # - New approach: has_data_model=True but no _structured_tool_call (JSON in text)
831+ uses_old_tool_approach = has_data_model and any (
832+ c .type == "tool_use" and c .name == "_structured_tool_call"
833+ for c in completion .content
834+ )
835+ uses_new_output_format = has_data_model and not uses_old_tool_approach
836+
756837 for content in completion .content :
757838 if content .type == "text" :
758- contents .append (ContentText (text = content .text ))
839+ if uses_new_output_format :
840+ # New API: JSON response is in text content
841+ contents .append (ContentJson (value = orjson .loads (content .text )))
842+ else :
843+ contents .append (ContentText (text = content .text ))
759844 elif content .type == "tool_use" :
760- if has_data_model and content .name == "_structured_tool_call" :
845+ if uses_old_tool_approach and content .name == "_structured_tool_call" :
846+ # Old API: extract from tool input
761847 if not isinstance (content .input , dict ):
762848 raise ValueError (
763849 "Expected data extraction tool to return a dictionary."
@@ -874,26 +960,30 @@ def batch_submit(
874960 requests : list ["BatchRequest" ] = []
875961
876962 for i , turns in enumerate (conversations ):
877- kwargs = self ._chat_perform_args (
963+ api_kwargs , use_beta = self ._chat_perform_args (
878964 stream = False ,
879965 turns = turns ,
880966 tools = {},
881967 data_model = data_model ,
882968 )
883969
884970 params : "MessageCreateParamsNonStreaming" = {
885- "messages" : kwargs .get ("messages" , {}),
971+ "messages" : api_kwargs .get ("messages" , {}),
886972 "model" : self .model ,
887- "max_tokens" : kwargs .get ("max_tokens" , 4096 ),
973+ "max_tokens" : api_kwargs .get ("max_tokens" , 4096 ),
888974 }
889975
890- # If data_model, tools/tool_choice should be present
891- tools = kwargs .get ("tools" )
892- tool_choice = kwargs .get ("tool_choice" )
976+ # If data_model, tools/tool_choice should be present (old API)
977+ # or output_format (new API)
978+ tools = api_kwargs .get ("tools" )
979+ tool_choice = api_kwargs .get ("tool_choice" )
980+ output_format = api_kwargs .get ("output_format" )
893981 if tools and not isinstance (tools , NotGiven ):
894982 params ["tools" ] = tools
895983 if tool_choice and not isinstance (tool_choice , NotGiven ):
896984 params ["tool_choice" ] = tool_choice
985+ if output_format and not isinstance (output_format , NotGiven ):
986+ params ["output_format" ] = output_format # type: ignore[typeddict-unknown-key]
897987
898988 requests .append ({"custom_id" : f"request-{ i } " , "params" : params })
899989
0 commit comments