multi_llm/providers/
openai.rs

1//! OpenAI provider implementation
2//!
3//! This provider uses the OpenAI-compatible shared structures and utilities.
4
5use super::openai_shared::{
6    http::OpenAICompatibleClient, utils::apply_config_to_request, OpenAIRequest, OpenAIResponse,
7};
8// Removed LLMClientCore import - providers now implement their own methods directly
9use crate::config::{DefaultLLMParams, OpenAIConfig};
10use crate::error::{LlmError, LlmResult};
11#[cfg(feature = "events")]
12use crate::internals::events::{event_types, BusinessEvent, EventScope};
13use crate::internals::response_parser::ResponseParser;
14use crate::logging::{log_debug, log_error};
15use crate::messages::{MessageContent, MessageRole, UnifiedLLMRequest, UnifiedMessage};
16#[cfg(feature = "events")]
17use crate::provider::LLMBusinessEvent;
18use crate::provider::{LlmProvider, RequestConfig, Response, TokenUsage, ToolCallingRound};
19use std::time::Instant;
20
21/// OpenAI provider implementation
22#[derive(Debug)]
23pub struct OpenAIProvider {
24    http_client: OpenAICompatibleClient,
25    config: OpenAIConfig,
26    default_params: DefaultLLMParams,
27}
28
29impl OpenAIProvider {
30    /// Create a new OpenAI provider instance
31    ///
32    /// # Errors
33    ///
34    /// Returns [`LlmError::ConfigurationError`] if:
35    /// - API key is missing or invalid
36    /// - Provider configuration validation fails
37    /// - HTTP client initialization fails
38    pub fn new(config: OpenAIConfig, default_params: DefaultLLMParams) -> LlmResult<Self> {
39        log_debug!(
40            provider = "openai",
41            has_api_key = config.api_key.is_some(),
42            max_context_tokens = config.max_context_tokens,
43            base_url = %config.base_url,
44            default_model = %config.default_model,
45            default_temperature = default_params.temperature,
46            "Creating OpenAI provider"
47        );
48
49        if config.api_key.is_none() {
50            return Err(LlmError::configuration_error("OpenAI API key is required"));
51        }
52
53        log_debug!(
54            provider = "openai",
55            max_context_tokens = config.max_context_tokens,
56            default_model = %config.default_model,
57            default_temperature = default_params.temperature,
58            "OpenAI provider initialized"
59        );
60
61        Ok(Self {
62            http_client: OpenAICompatibleClient::with_retry_policy(config.retry_policy.clone()),
63            config,
64            default_params,
65        })
66    }
67
68    /// Internal method for executor pattern - restore default retry policy
69    pub(crate) async fn restore_default_retry_policy(&self) {
70        // OpenAI provider doesn't need explicit retry policy restoration
71        // The client manages retry state internally
72    }
73
74    /// Create base OpenAI request from unified request
75    fn create_base_request(&self, request: &UnifiedLLMRequest) -> OpenAIRequest {
76        let openai_messages = self.transform_unified_messages(&request.get_sorted_messages());
77
78        OpenAIRequest {
79            model: self.config.default_model.clone(),
80            messages: openai_messages,
81            temperature: Some(self.default_params.temperature),
82            max_tokens: Some(self.default_params.max_tokens),
83            top_p: Some(self.default_params.top_p),
84            presence_penalty: None,
85            stream: None,
86            tools: None,
87            tool_choice: None,
88            response_format: None,
89        }
90    }
91
92    /// Apply response schema to request if present
93    fn apply_response_schema(
94        &self,
95        request: &mut OpenAIRequest,
96        schema: Option<serde_json::Value>,
97    ) {
98        if let Some(schema) = schema {
99            request.response_format = Some(super::openai_shared::OpenAIResponseFormat {
100                format_type: "json_schema".to_string(),
101                json_schema: Some(super::openai_shared::OpenAIJsonSchema {
102                    name: "structured_response".to_string(),
103                    schema,
104                    strict: Some(true),
105                }),
106            });
107        }
108    }
109
110    /// Send request to OpenAI API
111    async fn send_openai_request(
112        &self,
113        request: &OpenAIRequest,
114    ) -> crate::provider::Result<OpenAIResponse> {
115        // Construct full URL with path (consistent with LMStudio/Ollama)
116        let url = format!("{}/v1/chat/completions", self.config.base_url);
117
118        let headers = OpenAICompatibleClient::build_auth_headers(
119            self.config.api_key.as_ref().unwrap_or(&String::new()),
120        )?;
121        self.http_client
122            .execute_chat_request(&url, &headers, request)
123            .await
124    }
125
126    /// Create LLM request business event
127    #[cfg(feature = "events")]
128    fn create_request_event(
129        &self,
130        request: &OpenAIRequest,
131        config: Option<&RequestConfig>,
132    ) -> Option<LLMBusinessEvent> {
133        let user_id = config.and_then(|c| c.user_id.clone())?;
134
135        let event = BusinessEvent::new(event_types::LLM_REQUEST)
136            .with_metadata("provider", "openai")
137            .with_metadata("model", &request.model);
138
139        Some(LLMBusinessEvent {
140            event,
141            scope: EventScope::User(user_id),
142        })
143    }
144
145    /// Create LLM error business event
146    #[cfg(feature = "events")]
147    fn create_error_event(
148        &self,
149        error: &str,
150        config: Option<&RequestConfig>,
151    ) -> Option<LLMBusinessEvent> {
152        let user_id = config.and_then(|c| c.user_id.clone())?;
153
154        let event = BusinessEvent::new(event_types::LLM_ERROR)
155            .with_metadata("provider", "openai")
156            .with_metadata("error", error);
157
158        Some(LLMBusinessEvent {
159            event,
160            scope: EventScope::User(user_id),
161        })
162    }
163
164    /// Create LLM response business event
165    #[cfg(feature = "events")]
166    fn create_response_event(
167        &self,
168        api_response: &OpenAIResponse,
169        duration_ms: u64,
170        config: Option<&RequestConfig>,
171    ) -> Option<LLMBusinessEvent> {
172        let user_id = config.and_then(|c| c.user_id.clone())?;
173
174        let usage_tokens = api_response
175            .usage
176            .as_ref()
177            .map(|u| (u.prompt_tokens, u.completion_tokens));
178        let mut event = BusinessEvent::new(event_types::LLM_RESPONSE)
179            .with_metadata("provider", "openai")
180            .with_metadata("model", &self.config.default_model)
181            .with_metadata("input_tokens", usage_tokens.map(|(i, _)| i).unwrap_or(0))
182            .with_metadata("output_tokens", usage_tokens.map(|(_, o)| o).unwrap_or(0))
183            .with_metadata("duration_ms", duration_ms);
184
185        if let Some(ref sess_id) = config.and_then(|c| c.session_id.as_ref()) {
186            event = event.with_metadata("session_id", sess_id);
187        }
188
189        Some(LLMBusinessEvent {
190            event,
191            scope: EventScope::User(user_id),
192        })
193    }
194
195    /// Core LLM execution logic shared between events and non-events versions
196    ///
197    /// Returns (Response, OpenAIResponse, duration_ms, OpenAIRequest) to allow event creation
198    async fn execute_llm_internal(
199        &self,
200        request: UnifiedLLMRequest,
201        config: Option<RequestConfig>,
202    ) -> crate::provider::Result<(Response, OpenAIResponse, u64, OpenAIRequest)> {
203        // Create base request and apply config
204        let mut openai_request = self.create_base_request(&request);
205        if let Some(cfg) = config.as_ref() {
206            apply_config_to_request(&mut openai_request, Some(cfg.clone()));
207        }
208        self.apply_response_schema(&mut openai_request, request.response_schema);
209
210        log_debug!(
211            provider = "openai",
212            request_json = %serde_json::to_string(&openai_request).unwrap_or_default(),
213            "Executing LLM request"
214        );
215
216        // Clone request for event creation
217        let openai_request_for_events = openai_request.clone();
218
219        // Send to OpenAI API
220        let start_time = Instant::now();
221        let api_response = self.send_openai_request(&openai_request).await?;
222        let duration_ms = start_time.elapsed().as_millis() as u64;
223
224        // Parse response
225        let response = self.parse_openai_response(api_response.clone())?;
226
227        Ok((
228            response,
229            api_response,
230            duration_ms,
231            openai_request_for_events,
232        ))
233    }
234
235    /// Transform unified messages to OpenAI format
236    /// OpenAI includes system messages in the messages array and has automatic caching
237    fn transform_unified_messages(
238        &self,
239        messages: &[&UnifiedMessage],
240    ) -> Vec<super::openai_shared::OpenAIMessage> {
241        messages
242            .iter()
243            .map(|msg| self.unified_message_to_openai(msg))
244            .collect()
245    }
246
247    /// Convert a UnifiedMessage to OpenAI format
248    /// Note: OpenAI has automatic caching, so we don't need to handle cache_control
249    fn unified_message_to_openai(
250        &self,
251        msg: &UnifiedMessage,
252    ) -> super::openai_shared::OpenAIMessage {
253        let role = match msg.role {
254            MessageRole::System => "system".to_string(),
255            MessageRole::User => "user".to_string(),
256            MessageRole::Assistant => "assistant".to_string(),
257            MessageRole::Tool => "tool".to_string(),
258        };
259
260        let content = match &msg.content {
261            MessageContent::Text(text) => text.clone(),
262            MessageContent::Json(value) => serde_json::to_string_pretty(value).unwrap_or_default(),
263            MessageContent::ToolCall { .. } => {
264                // We should never be sending tool calls TO the LLM
265                log_error!(provider = "openai", "Unexpected ToolCall in outgoing message - tool calls are received from LLM, not sent to it");
266                "Error: Invalid message type".to_string()
267            }
268            MessageContent::ToolResult {
269                tool_call_id: _,
270                content,
271                is_error,
272            } => {
273                if *is_error {
274                    format!("Error: {}", content)
275                } else {
276                    content.clone()
277                }
278            }
279        };
280
281        super::openai_shared::OpenAIMessage { role, content }
282    }
283
284    /// Parse OpenAI response to Response
285    fn parse_openai_response(&self, response: OpenAIResponse) -> LlmResult<Response> {
286        let choice = response
287            .choices
288            .into_iter()
289            .next()
290            .ok_or_else(|| LlmError::response_parsing_error("No choices in OpenAI response"))?;
291
292        let content = choice.message.content;
293
294        let tool_calls = choice
295            .message
296            .tool_calls
297            .unwrap_or_default()
298            .into_iter()
299            .map(|tc| crate::provider::ToolCall {
300                id: tc.id,
301                name: tc.function.name,
302                arguments: serde_json::from_str(&tc.function.arguments).unwrap_or_default(),
303            })
304            .collect();
305
306        let usage = response.usage.map(|u| TokenUsage {
307            prompt_tokens: u.prompt_tokens,
308            completion_tokens: u.completion_tokens,
309            total_tokens: u.total_tokens,
310        });
311
312        // Handle structured response parsing if needed
313        let structured_response = if content.trim_start().starts_with('{') {
314            match ResponseParser::parse_llm_output(&content) {
315                Ok(json_value) => {
316                    log_debug!(
317                        provider = "openai",
318                        "Successfully parsed structured JSON response"
319                    );
320                    Some(json_value)
321                }
322                Err(_) => None,
323            }
324        } else {
325            None
326        };
327
328        Ok(Response {
329            content,
330            structured_response,
331            tool_calls,
332            usage,
333            model: Some(self.config.default_model.clone()),
334            raw_body: None,
335        })
336    }
337}
338
339#[async_trait::async_trait]
340impl LlmProvider for OpenAIProvider {
341    #[cfg(feature = "events")]
342    async fn execute_llm(
343        &self,
344        request: UnifiedLLMRequest,
345        _current_tool_round: Option<ToolCallingRound>,
346        config: Option<RequestConfig>,
347    ) -> crate::provider::Result<(Response, Vec<LLMBusinessEvent>)> {
348        let mut events = Vec::new();
349
350        // Execute core logic and collect event data
351        let (response, api_response, duration_ms, openai_request) =
352            match self.execute_llm_internal(request, config.clone()).await {
353                Ok(result) => result,
354                Err(e) => {
355                    // On error, log error event
356                    if let Some(event) = self.create_error_event(&e.to_string(), config.as_ref()) {
357                        events.push(event);
358                    }
359                    return Err(e);
360                }
361            };
362
363        // Log request event
364        if let Some(event) = self.create_request_event(&openai_request, config.as_ref()) {
365            events.push(event);
366        }
367
368        // Log response event
369        if let Some(event) = self.create_response_event(&api_response, duration_ms, config.as_ref())
370        {
371            events.push(event);
372        }
373
374        Ok((response, events))
375    }
376
377    #[cfg(not(feature = "events"))]
378    async fn execute_llm(
379        &self,
380        request: UnifiedLLMRequest,
381        _current_tool_round: Option<ToolCallingRound>,
382        config: Option<RequestConfig>,
383    ) -> crate::provider::Result<Response> {
384        // Simple wrapper - just return the response
385        let (response, _api_response, _duration_ms, _openai_request) =
386            self.execute_llm_internal(request, config).await?;
387        Ok(response)
388    }
389
390    #[cfg(feature = "events")]
391    async fn execute_structured_llm(
392        &self,
393        mut request: UnifiedLLMRequest,
394        current_tool_round: Option<ToolCallingRound>,
395        schema: serde_json::Value,
396        config: Option<RequestConfig>,
397    ) -> crate::provider::Result<(Response, Vec<LLMBusinessEvent>)> {
398        // Set the schema in the request
399        request.response_schema = Some(schema);
400
401        // Execute with the schema-enabled request (returns tuple with events)
402        self.execute_llm(request, current_tool_round, config).await
403    }
404
405    #[cfg(not(feature = "events"))]
406    async fn execute_structured_llm(
407        &self,
408        mut request: UnifiedLLMRequest,
409        current_tool_round: Option<ToolCallingRound>,
410        schema: serde_json::Value,
411        config: Option<RequestConfig>,
412    ) -> crate::provider::Result<Response> {
413        // Set the schema in the request
414        request.response_schema = Some(schema);
415
416        // Execute with the schema-enabled request
417        self.execute_llm(request, current_tool_round, config).await
418    }
419
420    fn provider_name(&self) -> &'static str {
421        "openai"
422    }
423}