@@ -114,6 +114,14 @@ pub fn accepts_object(schema: &Value) -> bool {
114114/// / `object`, and a bare scalar → `[scalar]` for `array`. A value that does
115115/// not convert is left as it was, so the schema validator still reports it.
116116/// Recurses into nested objects and array items with their own schemas.
117+ ///
118+ /// An object-typed property given a string that is not strict JSON goes
119+ /// through the same lenient [`super::json::recover_object`] ladder as a whole
120+ /// argument blob (trailing commas, bare keys, a fence, leaked template
121+ /// markers). A `null` for an **optional** property whose declared `type` does
122+ /// not admit null is dropped: the model meant "not given" (`"tool": null`),
123+ /// and the key's absence is what the schema accepts. A required property is
124+ /// never dropped, so the validator still names it.
117125#[ must_use]
118126pub fn coerce_to_schema ( arguments : Value , schema : & Value ) -> Value {
119127 let Value :: Object ( map) = arguments else {
@@ -122,9 +130,14 @@ pub fn coerce_to_schema(arguments: Value, schema: &Value) -> Value {
122130 let Some ( properties) = schema. get ( "properties" ) . and_then ( Value :: as_object) else {
123131 return Value :: Object ( map) ;
124132 } ;
133+ let required = schema. get ( "required" ) . and_then ( Value :: as_array) ;
134+ let is_required = |key : & str | required. is_some_and ( |list| list. iter ( ) . any ( |r| r == key) ) ;
125135 let mut out = Map :: with_capacity ( map. len ( ) ) ;
126136 for ( key, value) in map {
127137 let coerced = match properties. get ( & key) {
138+ Some ( property) if value. is_null ( ) && !is_required ( & key) && rejects_null ( property) => {
139+ continue ;
140+ }
128141 Some ( property) => coerce_value ( value, property) ,
129142 None => value,
130143 } ;
@@ -133,6 +146,16 @@ pub fn coerce_to_schema(arguments: Value, schema: &Value) -> Value {
133146 Value :: Object ( out)
134147}
135148
149+ /// Whether `schema` declares a `type` that does not include `null`. An
150+ /// untyped property accepts null already, so it is never rewritten.
151+ fn rejects_null ( schema : & Value ) -> bool {
152+ match schema. get ( "type" ) {
153+ Some ( Value :: String ( kind) ) => kind != "null" ,
154+ Some ( Value :: Array ( kinds) ) => !kinds. iter ( ) . any ( |kind| kind == "null" ) ,
155+ _ => false ,
156+ }
157+ }
158+
136159fn schema_type ( schema : & Value ) -> Option < & str > {
137160 match schema. get ( "type" ) {
138161 Some ( Value :: String ( s) ) => Some ( s. as_str ( ) ) ,
@@ -169,7 +192,11 @@ fn coerce_value(value: Value, schema: &Value) -> Value {
169192 ( Some ( "array" ) , scalar @ ( Value :: Number ( _) | Value :: Bool ( _) ) ) => Value :: Array ( vec ! [ scalar] ) ,
170193 ( Some ( "object" ) , Value :: String ( s) ) => match serde_json:: from_str :: < Value > ( s. trim ( ) ) {
171194 Ok ( object @ Value :: Object ( _) ) => coerce_to_schema ( object, schema) ,
172- _ => Value :: String ( s) ,
195+ Ok ( _) => Value :: String ( s) ,
196+ Err ( _) => match super :: json:: recover_object ( super :: json:: strip_code_fence ( & s) ) {
197+ Some ( object) => coerce_to_schema ( object, schema) ,
198+ None => Value :: String ( s) ,
199+ } ,
173200 } ,
174201 ( Some ( "object" ) , object @ Value :: Object ( _) ) => coerce_to_schema ( object, schema) ,
175202 ( Some ( "string" ) , Value :: Number ( n) ) => Value :: String ( n. to_string ( ) ) ,
0 commit comments