@ -1,8 +1,7 @@
//! OpenAI LLM provider implementation.
//!
//! Implements the `LlmProvider` trait using two OpenAI APIs:
//! - **Pass 1 (search)**: Responses API (`/v1/responses`) with `web_search_preview` tool
//! - **Pass 2 (rewrite)**: Chat Completions API (`/v1/chat/completions`) with structured output
//! Implements the `LlmProvider` trait using the OpenAI Responses API (`/v1/responses`)
//! with structured JSON output via `json_schema` text format.
use async_trait ::async_trait ;
use serde_json ::Value ;
@ -13,7 +12,7 @@ use crate::errors::AppError;
/// OpenAI provider.
///
/// Holds the API key and an HTTP client for making requests
/// to the OpenAI Responses and Chat Completions APIs .
/// to the OpenAI Responses API.
pub struct OpenAiProvider {
api_key : String ,
http_client : reqwest ::Client ,
@ -27,20 +26,22 @@ impl OpenAiProvider {
http_client ,
}
}
}
#[ async_trait ]
impl LlmProvider for OpenAiProvider {
fn provider_id ( & self ) -> & str {
"openai"
}
/// Execute a request to the OpenAI Responses API (Pass 1).
///
/// Uses the Responses API with `web_search_preview` tool for grounded search results
/// and structured output via `json_schema` text format.
async fn call_responses_api (
async fn call_llm (
& self ,
model : & str ,
system_prompt : & str ,
user_prompt : & str ,
response_schema : & Value ,
include_web_search : bool ,
) -> Result < Value , AppError > {
let mut body = serde_json ::json ! ( {
let body = serde_json ::json ! ( {
"model" : model ,
"instructions" : system_prompt ,
"input" : user_prompt ,
@ -55,12 +56,6 @@ impl OpenAiProvider {
}
} ) ;
if include_web_search {
body [ "tools" ] = serde_json ::json ! ( [ {
"type" : "web_search_preview"
} ] ) ;
}
let response = self
. http_client
. post ( "https://api.openai.com/v1/responses" )
@ -93,106 +88,6 @@ impl OpenAiProvider {
extract_responses_api_content ( & response_body )
}
/// Execute a request to the OpenAI Chat Completions API (Pass 2).
///
/// Uses the Chat Completions API with `json_schema` response format
/// for structured output without web search.
async fn call_chat_completions_api (
& self ,
model : & str ,
system_prompt : & str ,
user_prompt : & str ,
response_schema : & Value ,
) -> Result < Value , AppError > {
let body = serde_json ::json ! ( {
"model" : model ,
"messages" : [
{
"role" : "system" ,
"content" : system_prompt
} ,
{
"role" : "user" ,
"content" : user_prompt
}
] ,
"max_tokens" : 16384 ,
"response_format" : {
"type" : "json_schema" ,
"json_schema" : {
"name" : "synthesis" ,
"strict" : true ,
"schema" : response_schema
}
}
} ) ;
let response = self
. http_client
. post ( "https://api.openai.com/v1/chat/completions" )
. header ( "Authorization" , format! ( "Bearer {}" , self . api_key ) )
. header ( "Content-Type" , "application/json" )
. json ( & body )
. send ( )
. await
. map_err ( | e | {
let kind = if e . is_timeout ( ) {
"timeout"
} else if e . is_connect ( ) {
"connection error"
} else {
"network error"
} ;
tracing ::error ! ( "OpenAI Chat Completions API request failed: {}" , kind ) ;
AppError ::Internal ( anyhow ::anyhow ! ( "Failed to connect to OpenAI API" ) )
} ) ? ;
let status = response . status ( ) ;
let response_body : Value = response . json ( ) . await . map_err ( | e | {
tracing ::error ! ( "Failed to parse OpenAI response body: {}" , e ) ;
AppError ::Internal ( anyhow ::anyhow ! ( "Failed to parse OpenAI API response" ) )
} ) ? ;
if ! status . is_success ( ) {
return Err ( map_openai_error ( status . as_u16 ( ) , & response_body ) ) ;
}
extract_chat_completions_content ( & response_body )
}
}
#[ async_trait ]
impl LlmProvider for OpenAiProvider {
fn provider_id ( & self ) -> & str {
"openai"
}
async fn generate_search_pass (
& self ,
model : & str ,
system_prompt : & str ,
user_prompt : & str ,
response_schema : & Value ,
) -> Result < Value , AppError > {
self . call_responses_api ( model , system_prompt , user_prompt , response_schema , true )
. await
}
async fn generate_rewrite_pass (
& self ,
model : & str ,
system_prompt : & str ,
user_prompt : & str ,
response_schema : & Value ,
) -> Result < Value , AppError > {
self . call_chat_completions_api ( model , system_prompt , user_prompt , response_schema )
. await
}
fn supports_web_search ( & self ) -> bool {
true
}
}
/// Extract the text content from an OpenAI Responses API response.
@ -257,34 +152,6 @@ fn extract_responses_api_content(response: &Value) -> Result<Value, AppError> {
) ) )
}
/// Extract the text content from an OpenAI Chat Completions API response.
///
/// The response structure is:
/// ```json
/// { "choices": [{ "message": { "content": "..." } }] }
/// ```
fn extract_chat_completions_content ( response : & Value ) -> Result < Value , AppError > {
let text = response
. get ( "choices" )
. and_then ( | c | c . get ( 0 ) )
. and_then ( | c | c . get ( "message" ) )
. and_then ( | m | m . get ( "content" ) )
. and_then ( | t | t . as_str ( ) )
. ok_or_else ( | | {
tracing ::error ! ( "Unexpected OpenAI Chat Completions response structure" ) ;
AppError ::Internal ( anyhow ::anyhow ! (
"OpenAI Chat Completions API returned an unexpected response structure"
) )
} ) ? ;
serde_json ::from_str ( text ) . map_err ( | e | {
tracing ::error ! ( "Failed to parse OpenAI Chat Completions JSON output: {}" , e ) ;
AppError ::Internal ( anyhow ::anyhow ! (
"OpenAI returned invalid JSON in structured output"
) )
} )
}
/// Map OpenAI API error responses to appropriate `AppError` variants.
///
/// Handles common error codes without exposing internal details.
@ -325,13 +192,12 @@ fn map_openai_error(status: u16, body: &Value) -> AppError {
mod tests {
use super ::* ;
// ── Request body tests ──────────────────────────────────────
// ── Provider metadata ─ ──────────────────────────────────────
#[ test ]
fn openai_provider_metadata ( ) {
let provider = OpenAiProvider ::new ( "test-key" . into ( ) , reqwest ::Client ::new ( ) ) ;
assert_eq! ( provider . provider_id ( ) , "openai" ) ;
assert! ( provider . supports_web_search ( ) ) ;
}
// ── Responses API response parsing ──────────────────────────
@ -420,52 +286,6 @@ mod tests {
assert! ( extract_responses_api_content ( & response ) . is_err ( ) ) ;
}
// ── Chat Completions response parsing ───────────────────────
#[ test ]
fn extract_chat_completions_content_valid ( ) {
let response = serde_json ::json ! ( {
"choices" : [ {
"message" : {
"role" : "assistant" ,
"content" : "{\"category_0\": [{\"title\": \"Rewritten\", \"url\": \"https://example.com\", \"summary\": \"Rewritten summary\"}]}"
} ,
"finish_reason" : "stop"
} ]
} ) ;
let result = extract_chat_completions_content ( & response ) . unwrap ( ) ;
assert! ( result [ "category_0" ] . is_array ( ) ) ;
assert_eq! (
result [ "category_0" ] [ 0 ] [ "title" ] . as_str ( ) . unwrap ( ) ,
"Rewritten"
) ;
}
#[ test ]
fn extract_chat_completions_content_missing_choices ( ) {
let response = serde_json ::json ! ( { } ) ;
assert! ( extract_chat_completions_content ( & response ) . is_err ( ) ) ;
}
#[ test ]
fn extract_chat_completions_content_empty_choices ( ) {
let response = serde_json ::json ! ( { "choices" : [ ] } ) ;
assert! ( extract_chat_completions_content ( & response ) . is_err ( ) ) ;
}
#[ test ]
fn extract_chat_completions_content_invalid_json ( ) {
let response = serde_json ::json ! ( {
"choices" : [ {
"message" : {
"content" : "this is not json"
}
} ]
} ) ;
assert! ( extract_chat_completions_content ( & response ) . is_err ( ) ) ;
}
// ── Error mapping tests ─────────────────────────────────────
#[ test ]