66
77from pydantic import BaseModel , TypeAdapter
88
9- from celeste .exceptions import ConstraintViolationError , ValidationError
9+ from celeste .exceptions import ConstraintViolationError
1010from celeste .models import Model
1111from celeste .parameters import ParameterMapper
1212from celeste_text_generation .parameters import TextGenerationParameter
@@ -62,7 +62,7 @@ def map(
6262
6363
6464class OutputSchemaMapper (ParameterMapper ):
65- """Map output_schema parameter to Anthropic tools parameter (tool-based structured output )."""
65+ """Map output_schema parameter to Anthropic native structured outputs (output_format )."""
6666
6767 name : StrEnum = TextGenerationParameter .OUTPUT_SCHEMA
6868
@@ -72,55 +72,44 @@ def map(
7272 value : object ,
7373 model : Model ,
7474 ) -> dict [str , Any ]:
75- """Transform output_schema into provider request.
75+ """Transform output_schema into provider request using native structured outputs .
7676
77- Converts unified output_schema to Anthropic tools parameter:
78- - Creates a single tool definition with input_schema matching the output schema
79- - Sets tool_choice to force tool use
77+ Converts unified output_schema to Anthropic output_format parameter:
78+ - Uses output_format with type: "json_schema" and schema definition
8079 - Handles both BaseModel and list[BaseModel] types
80+ - For list[BaseModel], schema is array type directly
8181
8282 Args:
8383 request: Provider request dict.
8484 value: output_schema value (type[BaseModel] | None).
8585 model: Model instance containing parameter_constraints for validation.
86- model: Model instance with parameter constraints for validation.
8786
8887 Returns:
89- Updated request dict with tools and tool_choice if value provided.
88+ Updated request dict with output_format if value provided.
9089 """
9190 validated_value = self ._validate_value (value , model )
9291 if validated_value is None :
9392 return request
9493
95- # Convert Pydantic model to JSON Schema
96- schema = self ._convert_to_anthropic_schema (validated_value )
97- tool_name = self ._get_tool_name (validated_value )
94+ schema = self ._convert_to_json_schema (validated_value )
9895
99- # Create tool definition with input_schema matching output schema
100- tool_def = {
101- "name" : tool_name ,
102- "description" : f"Extract structured data conforming to { self ._get_schema_description (validated_value )} " ,
103- "input_schema" : schema ,
96+ request ["output_format" ] = {
97+ "type" : "json_schema" ,
98+ "schema" : schema ,
10499 }
105100
106- # Add tools array to request
107- request ["tools" ] = [tool_def ]
108-
109- # Force tool use by setting tool_choice
110- request ["tool_choice" ] = {"type" : "tool" , "name" : tool_name }
111-
112101 return request
113102
114103 def parse_output (
115104 self , content : str | dict [str , Any ], value : object | None
116105 ) -> str | BaseModel :
117- """Parse tool_use blocks from response to BaseModel instance.
106+ """Parse JSON text from response to BaseModel instance.
118107
119- Extracts structured data from tool_use.input field and converts to BaseModel .
120- For list[BaseModel], extracts the "items" array from the wrapped object .
108+ With native structured outputs, content is direct JSON text in content[0].text .
109+ For list[BaseModel], content is array directly .
121110
122111 Args:
123- content: Either tool_use.input dict ( from tool_use block) or JSON string .
112+ content: JSON string from content[0].text .
124113 value: Original output_schema parameter value.
125114
126115 Returns:
@@ -129,72 +118,69 @@ def parse_output(
129118 if value is None :
130119 return content if isinstance (content , str ) else json .dumps (content )
131120
132- # If content is already a dict (from tool_use.input), use it directly
133121 if isinstance (content , dict ):
134122 parsed_json = content
135123 else :
136- # Otherwise parse as JSON string
137124 parsed_json = json .loads (content )
138125
139- # Check if value is list[BaseModel] and content is wrapped in object
140- origin = get_origin (value )
141- if origin is list :
142- # Handle empty dict case FIRST - convert to empty array before checking for "items"
143- if isinstance (parsed_json , dict ) and not parsed_json :
144- # Empty dict when expecting list - convert to empty array
145- parsed_json = []
146- elif isinstance (parsed_json , dict ) and "items" in parsed_json :
147- # Extract items array from wrapped format
148- parsed_json = parsed_json ["items" ]
149- # If it's already an array (backward compatibility), use it directly
150- # parsed_json is now the array, ready for TypeAdapter
151- elif isinstance (parsed_json , dict ) and not parsed_json :
152- # Empty dict for BaseModel (not list) - this is invalid, raise error
153- msg = "Empty tool_use input dict cannot be converted to BaseModel"
154- raise ValidationError (msg )
155-
156- # Parse to BaseModel instance using TypeAdapter
157- # TypeAdapter handles both BaseModel and list[BaseModel]
158126 return TypeAdapter (value ).validate_json (json .dumps (parsed_json ))
159127
160- def _convert_to_anthropic_schema (self , output_schema : Any ) -> dict [str , Any ]: # noqa: ANN401
161- """Convert Pydantic BaseModel or list[BaseModel] to Anthropic JSON Schema format.
128+ def _convert_to_json_schema (self , output_schema : Any ) -> dict [str , Any ]: # noqa: ANN401
129+ """Convert Pydantic BaseModel or list[BaseModel] to JSON Schema format.
162130
163- Anthropic requires input_schema to always be an object type.
164- For list[T], wraps array schema in an object with "items" property .
131+ For native structured outputs, list[T] is array type directly .
132+ Ensures all object types have additionalProperties: false as required by Anthropic .
165133
166134 Args:
167135 output_schema: Pydantic BaseModel class or list[BaseModel] type.
168136
169137 Returns:
170- JSON Schema dictionary compatible with Anthropic (always object type) .
138+ JSON Schema dictionary compatible with Anthropic structured outputs .
171139 """
172140 origin = get_origin (output_schema )
173141 if origin is list :
174- # For list[T], wrap array schema in an object (Anthropic requirement)
175142 inner_type = get_args (output_schema )[0 ]
176143 items_schema = inner_type .model_json_schema ()
177- # Resolve refs in items schema first
178144 items_schema = self ._resolve_refs (items_schema )
179- # Wrap in object with "items" property
180145 json_schema = {
181- "type" : "object" ,
182- "properties" : {
183- "items" : {
184- "type" : "array" ,
185- "items" : items_schema ,
186- },
187- },
188- "required" : ["items" ],
146+ "type" : "array" ,
147+ "items" : items_schema ,
189148 }
190149 else :
191- # For BaseModel, use model_json_schema directly
192150 json_schema = output_schema .model_json_schema ()
193- # Resolve $ref references inline (Anthropic may not support $ref)
194151 json_schema = self ._resolve_refs (json_schema )
195152
153+ json_schema = self ._ensure_additional_properties (json_schema )
196154 return json_schema
197155
156+ def _ensure_additional_properties (self , schema : dict [str , Any ]) -> dict [str , Any ]:
157+ """Ensure all object types have additionalProperties: false."""
158+ if not isinstance (schema , dict ):
159+ return schema
160+
161+ schema = schema .copy ()
162+
163+ if schema .get ("type" ) == "object" :
164+ schema ["additionalProperties" ] = False
165+
166+ for key in ["properties" , "items" ]:
167+ if key in schema :
168+ if key == "properties" :
169+ schema [key ] = {
170+ k : self ._ensure_additional_properties (v )
171+ for k , v in schema [key ].items ()
172+ }
173+ else :
174+ schema [key ] = self ._ensure_additional_properties (schema [key ])
175+
176+ for key in ["anyOf" , "allOf" ]:
177+ if key in schema :
178+ schema [key ] = [
179+ self ._ensure_additional_properties (item ) for item in schema [key ]
180+ ]
181+
182+ return schema
183+
198184 def _resolve_refs (self , schema : dict [str , Any ]) -> dict [str , Any ]:
199185 """Resolve all $ref references and inline definitions.
200186
@@ -250,36 +236,6 @@ def resolve(value: Any) -> Any: # noqa: ANN401
250236
251237 return resolve (schema )
252238
253- def _get_tool_name (self , output_schema : Any ) -> str : # noqa: ANN401
254- """Derive tool name from model class name.
255-
256- Args:
257- output_schema: Pydantic BaseModel class or list[BaseModel] type.
258-
259- Returns:
260- Tool name (lowercase class name or "extract_data" as fallback).
261- """
262- origin = get_origin (output_schema )
263- if origin is list :
264- inner_type = get_args (output_schema )[0 ]
265- return inner_type .__name__ .lower () or "extract_data"
266- return output_schema .__name__ .lower () or "extract_data"
267-
268- def _get_schema_description (self , output_schema : Any ) -> str : # noqa: ANN401
269- """Get description for tool definition.
270-
271- Args:
272- output_schema: Pydantic BaseModel class or list[BaseModel] type.
273-
274- Returns:
275- Schema description string.
276- """
277- origin = get_origin (output_schema )
278- if origin is list :
279- inner_type = get_args (output_schema )[0 ]
280- return f"array of { inner_type .__name__ } "
281- return output_schema .__name__
282-
283239
284240ANTHROPIC_PARAMETER_MAPPERS : list [ParameterMapper ] = [
285241 ThinkingBudgetMapper (),
0 commit comments