@@ -132,6 +132,19 @@ struct StreamOptions {
132132 include_usage : bool ,
133133}
134134
135+ const RESERVED_EXTRA_BODY_FIELDS : & [ & str ] = & [
136+ "model" ,
137+ "messages" ,
138+ "stream" ,
139+ "stream_options" ,
140+ "tools" ,
141+ "temperature" ,
142+ "top_p" ,
143+ "max_tokens" ,
144+ "max_completion_tokens" ,
145+ "reasoning_effort" ,
146+ ] ;
147+
135148#[ derive( Serialize ) ]
136149struct OpenAIMessage {
137150 role : String ,
@@ -727,6 +740,22 @@ fn normalized_max_completion_tokens<P: OpenAICompatPolicy>(
727740 } )
728741}
729742
743+ fn merge_model_extra_body (
744+ extra : & mut Map < String , Value > ,
745+ custom : Option < & Map < String , Value > > ,
746+ ) {
747+ let Some ( custom) = custom else {
748+ return ;
749+ } ;
750+
751+ for ( key, value) in custom {
752+ if RESERVED_EXTRA_BODY_FIELDS . contains ( & key. as_str ( ) ) {
753+ continue ;
754+ }
755+ extra. insert ( key. clone ( ) , value. clone ( ) ) ;
756+ }
757+ }
758+
730759fn build_request < P : OpenAICompatPolicy > (
731760 policy : & P ,
732761 request : & ChatRequest ,
@@ -740,7 +769,8 @@ fn build_request<P: OpenAICompatPolicy>(
740769 let effort = r. reasoning_effort . clone ( ) ?;
741770 policy. normalize_reasoning_effort ( & r. level , effort)
742771 } ) ;
743- let extra = policy. extra_body_fields ( reasoning. as_ref ( ) ) ;
772+ let mut extra = policy. extra_body_fields ( reasoning. as_ref ( ) ) ;
773+ merge_model_extra_body ( & mut extra, request. extra_body . as_ref ( ) ) ;
744774
745775 // Use max_completion_tokens only when the model/request contract requires it.
746776 let use_completion_tokens = policy. use_max_completion_tokens ( request) ;
@@ -838,6 +868,7 @@ mod tests {
838868 reasoning_profile : None ,
839869 use_max_completion_tokens : None ,
840870 thinking_param_style : None ,
871+ extra_body : None ,
841872 }
842873 }
843874
@@ -1394,6 +1425,53 @@ mod tests {
13941425 assert ! ( serialized. get( "temperature" ) . is_none( ) ) ;
13951426 assert ! ( serialized. get( "top_p" ) . is_none( ) ) ;
13961427 }
1428+
1429+ #[ test]
1430+ fn openai_compat_flattens_model_extra_body_fields ( ) {
1431+ let mut request = base_chat_request ( "gpt-4o" ) ;
1432+ request. extra_body = Some (
1433+ serde_json:: json!( {
1434+ "enable_thinking" : true ,
1435+ "vendor_options" : {
1436+ "trace" : "enabled"
1437+ }
1438+ } )
1439+ . as_object ( )
1440+ . expect ( "object" )
1441+ . clone ( ) ,
1442+ ) ;
1443+
1444+ let body = build_request ( & OpenAIPolicy , & request, & request. messages , true ) ;
1445+ let serialized = serde_json:: to_value ( body) . expect ( "request json" ) ;
1446+
1447+ assert_eq ! ( serialized[ "enable_thinking" ] , json!( true ) ) ;
1448+ assert_eq ! ( serialized[ "vendor_options" ] [ "trace" ] , json!( "enabled" ) ) ;
1449+ assert ! ( serialized. get( "extra_body" ) . is_none( ) ) ;
1450+ }
1451+
1452+ #[ test]
1453+ fn openai_compat_extra_body_cannot_override_core_fields ( ) {
1454+ let mut request = base_chat_request ( "gpt-4o" ) ;
1455+ request. extra_body = Some (
1456+ serde_json:: json!( {
1457+ "model" : "other-model" ,
1458+ "stream" : false ,
1459+ "max_tokens" : 1 ,
1460+ "enable_thinking" : true
1461+ } )
1462+ . as_object ( )
1463+ . expect ( "object" )
1464+ . clone ( ) ,
1465+ ) ;
1466+
1467+ let body = build_request ( & OpenAIPolicy , & request, & request. messages , true ) ;
1468+ let serialized = serde_json:: to_value ( body) . expect ( "request json" ) ;
1469+
1470+ assert_eq ! ( serialized[ "model" ] , json!( "gpt-4o" ) ) ;
1471+ assert_eq ! ( serialized[ "stream" ] , json!( true ) ) ;
1472+ assert_eq ! ( serialized[ "max_tokens" ] , json!( 300_000 ) ) ;
1473+ assert_eq ! ( serialized[ "enable_thinking" ] , json!( true ) ) ;
1474+ }
13971475}
13981476
13991477#[ async_trait]
0 commit comments