11use crate :: adapters:: common;
22use crate :: adapters:: llm:: {
33 ChatRequest , ChatResponse , ChatRole , ContentBlock , LLMAdapter , LLMRequest , LLMResponse ,
4- ModelConfig , StopReason , Usage ,
4+ ModelConfig , StopReason , StructuredOutputSchema , Usage ,
55} ;
66use anyhow:: { Context , Result } ;
77use async_trait:: async_trait;
@@ -22,6 +22,8 @@ struct OpenAIRequest {
2222 messages : Vec < Message > ,
2323 temperature : f32 ,
2424 max_tokens : usize ,
25+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
26+ response_format : Option < OpenAIResponseFormat > ,
2527}
2628
2729#[ derive( Serialize ) ]
@@ -45,6 +47,20 @@ struct Message {
4547 content : String ,
4648}
4749
50+ #[ derive( Serialize ) ]
51+ struct OpenAIResponseFormat {
52+ #[ serde( rename = "type" ) ]
53+ format_type : String ,
54+ json_schema : OpenAIJsonSchemaFormat ,
55+ }
56+
57+ #[ derive( Serialize ) ]
58+ struct OpenAIJsonSchemaFormat {
59+ name : String ,
60+ schema : serde_json:: Value ,
61+ strict : bool ,
62+ }
63+
4864#[ derive( Deserialize ) ]
4965struct OpenAIResponse {
5066 choices : Vec < Choice > ,
@@ -221,7 +237,15 @@ impl OpenAIAdapter {
221237
222238#[ async_trait]
223239impl LLMAdapter for OpenAIAdapter {
224- async fn complete ( & self , request : LLMRequest ) -> Result < LLMResponse > {
240+ async fn complete ( & self , mut request : LLMRequest ) -> Result < LLMResponse > {
241+ if request. response_schema . is_some ( ) {
242+ if self . supports_native_response_schema ( ) {
243+ return self . complete_chat_completions ( request) . await ;
244+ }
245+
246+ request. response_schema = None ;
247+ }
248+
225249 if should_use_responses_api ( & self . config ) {
226250 return self . complete_responses ( request) . await ;
227251 }
@@ -482,6 +506,12 @@ fn should_use_responses_api(config: &ModelConfig) -> bool {
482506}
483507
484508impl OpenAIAdapter {
509+ fn supports_native_response_schema ( & self ) -> bool {
510+ self . base_url . contains ( "api.openai.com" )
511+ || self . base_url . contains ( "127.0.0.1" )
512+ || self . base_url . contains ( "localhost" )
513+ }
514+
485515 async fn complete_chat_completions ( & self , request : LLMRequest ) -> Result < LLMResponse > {
486516 let messages = vec ! [
487517 Message {
@@ -499,6 +529,10 @@ impl OpenAIAdapter {
499529 messages,
500530 temperature : request. temperature . unwrap_or ( self . config . temperature ) ,
501531 max_tokens : request. max_tokens . unwrap_or ( self . config . max_tokens ) ,
532+ response_format : request
533+ . response_schema
534+ . as_ref ( )
535+ . map ( to_openai_response_format) ,
502536 } ;
503537
504538 let url = format ! ( "{}/chat/completions" , self . base_url) ;
@@ -576,6 +610,17 @@ impl OpenAIAdapter {
576610 }
577611}
578612
613+ fn to_openai_response_format ( schema : & StructuredOutputSchema ) -> OpenAIResponseFormat {
614+ OpenAIResponseFormat {
615+ format_type : "json_schema" . to_string ( ) ,
616+ json_schema : OpenAIJsonSchemaFormat {
617+ name : schema. name . clone ( ) ,
618+ schema : schema. schema . clone ( ) ,
619+ strict : schema. strict ,
620+ } ,
621+ }
622+ }
623+
579624fn extract_response_text ( response : & OpenAIResponsesResponse ) -> String {
580625 let mut combined = String :: new ( ) ;
581626
@@ -603,8 +648,9 @@ mod tests {
603648 use super :: * ;
604649 use crate :: adapters:: llm:: {
605650 ChatMessage , ChatRequest , ContentBlock as CB , LLMAdapter , LLMRequest , ModelConfig ,
606- StopReason , ToolDefinition ,
651+ StopReason , StructuredOutputSchema , ToolDefinition ,
607652 } ;
653+ use mockito:: Matcher ;
608654
609655 fn test_config ( base_url : & str ) -> ModelConfig {
610656 ModelConfig {
@@ -625,9 +671,57 @@ mod tests {
625671 user_prompt : "user" . to_string ( ) ,
626672 temperature : None ,
627673 max_tokens : None ,
674+ response_schema : None ,
628675 }
629676 }
630677
678+ #[ tokio:: test]
679+ async fn test_structured_output_schema_uses_chat_response_format ( ) {
680+ let mut server = mockito:: Server :: new_async ( ) . await ;
681+ let mock = server
682+ . mock ( "POST" , "/chat/completions" )
683+ . match_body ( Matcher :: PartialJsonString (
684+ serde_json:: json!( {
685+ "response_format" : {
686+ "type" : "json_schema" ,
687+ "json_schema" : {
688+ "name" : "review_findings" ,
689+ "strict" : true
690+ }
691+ }
692+ } )
693+ . to_string ( ) ,
694+ ) )
695+ . with_status ( 200 )
696+ . with_header ( "content-type" , "application/json" )
697+ . with_body (
698+ r#"{
699+ "choices": [{"message": {"role": "assistant", "content": "[]"}}],
700+ "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
701+ "model": "gpt-4o"
702+ }"# ,
703+ )
704+ . create_async ( )
705+ . await ;
706+
707+ let adapter = OpenAIAdapter :: new ( test_config ( & server. url ( ) ) ) . unwrap ( ) ;
708+ let result = adapter
709+ . complete ( LLMRequest {
710+ system_prompt : "system" . to_string ( ) ,
711+ user_prompt : "user" . to_string ( ) ,
712+ temperature : None ,
713+ max_tokens : None ,
714+ response_schema : Some ( StructuredOutputSchema :: json_schema (
715+ "review_findings" ,
716+ serde_json:: json!( { "type" : "array" } ) ,
717+ ) ) ,
718+ } )
719+ . await ;
720+
721+ assert ! ( result. is_ok( ) ) ;
722+ mock. assert_async ( ) . await ;
723+ }
724+
631725 #[ tokio:: test]
632726 async fn test_successful_completion ( ) {
633727 let mut server = mockito:: Server :: new_async ( ) . await ;
@@ -1103,6 +1197,7 @@ mod tests {
11031197 user_prompt : "u" . to_string ( ) ,
11041198 temperature : Some ( 0.8 ) ,
11051199 max_tokens : Some ( 500 ) ,
1200+ response_schema : None ,
11061201 } )
11071202 . await ;
11081203
0 commit comments