vtcode_core/llm/providers/
openai.rs

1use crate::config::constants::{models, urls};
2use crate::config::core::{OpenAIPromptCacheSettings, PromptCachingConfig};
3use crate::llm::client::LLMClient;
4use crate::llm::error_display;
5use crate::llm::provider::{
6    FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, Message, MessageRole, ToolCall,
7    ToolChoice, ToolDefinition,
8};
9use crate::llm::types as llm_types;
10use async_trait::async_trait;
11use reqwest::Client as HttpClient;
12use serde_json::{Value, json};
13
14use super::{extract_reasoning_trace, gpt5_codex_developer_prompt};
15
16pub struct OpenAIProvider {
17    api_key: String,
18    http_client: HttpClient,
19    base_url: String,
20    model: String,
21    prompt_cache_enabled: bool,
22    prompt_cache_settings: OpenAIPromptCacheSettings,
23}
24
25impl OpenAIProvider {
26    fn serialize_tools(tools: &[ToolDefinition]) -> Option<Value> {
27        if tools.is_empty() {
28            return None;
29        }
30
31        let serialized_tools = tools.iter().map(|tool| json!(tool)).collect::<Vec<Value>>();
32
33        Some(Value::Array(serialized_tools))
34    }
35
36    fn is_gpt5_codex_model(model: &str) -> bool {
37        model == models::openai::GPT_5_CODEX
38    }
39
40    fn is_reasoning_model(model: &str) -> bool {
41        models::openai::REASONING_MODELS
42            .iter()
43            .any(|candidate| *candidate == model)
44    }
45
46    fn uses_responses_api(model: &str) -> bool {
47        Self::is_gpt5_codex_model(model) || Self::is_reasoning_model(model)
48    }
49
50    pub fn new(api_key: String) -> Self {
51        Self::with_model_internal(api_key, models::openai::DEFAULT_MODEL.to_string(), None)
52    }
53
54    pub fn with_model(api_key: String, model: String) -> Self {
55        Self::with_model_internal(api_key, model, None)
56    }
57
58    pub fn from_config(
59        api_key: Option<String>,
60        model: Option<String>,
61        base_url: Option<String>,
62        prompt_cache: Option<PromptCachingConfig>,
63    ) -> Self {
64        let api_key_value = api_key.unwrap_or_default();
65        let mut provider = if let Some(model_value) = model {
66            Self::with_model_internal(api_key_value, model_value, prompt_cache)
67        } else {
68            Self::with_model_internal(
69                api_key_value,
70                models::openai::DEFAULT_MODEL.to_string(),
71                prompt_cache,
72            )
73        };
74        if let Some(base) = base_url {
75            provider.base_url = base;
76        }
77        provider
78    }
79
80    fn with_model_internal(
81        api_key: String,
82        model: String,
83        prompt_cache: Option<PromptCachingConfig>,
84    ) -> Self {
85        let (prompt_cache_enabled, prompt_cache_settings) =
86            Self::extract_prompt_cache_settings(prompt_cache);
87
88        Self {
89            api_key,
90            http_client: HttpClient::new(),
91            base_url: urls::OPENAI_API_BASE.to_string(),
92            model,
93            prompt_cache_enabled,
94            prompt_cache_settings,
95        }
96    }
97
98    fn extract_prompt_cache_settings(
99        prompt_cache: Option<PromptCachingConfig>,
100    ) -> (bool, OpenAIPromptCacheSettings) {
101        if let Some(cfg) = prompt_cache {
102            let provider_settings = cfg.providers.openai;
103            let enabled = cfg.enabled && provider_settings.enabled;
104            (enabled, provider_settings)
105        } else {
106            (false, OpenAIPromptCacheSettings::default())
107        }
108    }
109
110    fn supports_temperature_parameter(model: &str) -> bool {
111        // GPT-5 variants and GPT-5 Codex models don't support temperature parameter
112        // All other OpenAI models generally support it
113        !Self::is_gpt5_codex_model(model)
114            && model != models::openai::GPT_5
115            && model != models::openai::GPT_5_MINI
116            && model != models::openai::GPT_5_NANO
117    }
118
119    fn default_request(&self, prompt: &str) -> LLMRequest {
120        LLMRequest {
121            messages: vec![Message::user(prompt.to_string())],
122            system_prompt: None,
123            tools: None,
124            model: self.model.clone(),
125            max_tokens: None,
126            temperature: None,
127            stream: false,
128            tool_choice: None,
129            parallel_tool_calls: None,
130            parallel_tool_config: None,
131            reasoning_effort: None,
132        }
133    }
134
135    fn parse_client_prompt(&self, prompt: &str) -> LLMRequest {
136        let trimmed = prompt.trim_start();
137        if trimmed.starts_with('{') {
138            if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
139                if let Some(request) = self.parse_chat_request(&value) {
140                    return request;
141                }
142            }
143        }
144
145        self.default_request(prompt)
146    }
147
148    fn parse_chat_request(&self, value: &Value) -> Option<LLMRequest> {
149        let messages_value = value.get("messages")?.as_array()?;
150        let mut system_prompt = None;
151        let mut messages = Vec::new();
152
153        for entry in messages_value {
154            let role = entry
155                .get("role")
156                .and_then(|r| r.as_str())
157                .unwrap_or(crate::config::constants::message_roles::USER);
158            let content = entry.get("content");
159            let text_content = content.map(Self::extract_content_text).unwrap_or_default();
160
161            match role {
162                "system" => {
163                    if system_prompt.is_none() && !text_content.is_empty() {
164                        system_prompt = Some(text_content);
165                    }
166                }
167                "assistant" => {
168                    let tool_calls = entry
169                        .get("tool_calls")
170                        .and_then(|tc| tc.as_array())
171                        .map(|calls| {
172                            calls
173                                .iter()
174                                .filter_map(|call| {
175                                    let id = call.get("id").and_then(|v| v.as_str())?;
176                                    let function = call.get("function")?;
177                                    let name = function.get("name").and_then(|v| v.as_str())?;
178                                    let arguments = function.get("arguments");
179                                    let serialized = arguments.map_or("{}".to_string(), |value| {
180                                        if value.is_string() {
181                                            value.as_str().unwrap_or("").to_string()
182                                        } else {
183                                            value.to_string()
184                                        }
185                                    });
186                                    Some(ToolCall::function(
187                                        id.to_string(),
188                                        name.to_string(),
189                                        serialized,
190                                    ))
191                                })
192                                .collect::<Vec<_>>()
193                        })
194                        .filter(|calls| !calls.is_empty());
195
196                    let message = if let Some(calls) = tool_calls {
197                        Message {
198                            role: MessageRole::Assistant,
199                            content: text_content,
200                            tool_calls: Some(calls),
201                            tool_call_id: None,
202                        }
203                    } else {
204                        Message::assistant(text_content)
205                    };
206                    messages.push(message);
207                }
208                "tool" => {
209                    let tool_call_id = entry
210                        .get("tool_call_id")
211                        .and_then(|id| id.as_str())
212                        .map(|s| s.to_string());
213                    let content_value = entry
214                        .get("content")
215                        .map(|value| {
216                            if text_content.is_empty() {
217                                value.to_string()
218                            } else {
219                                text_content.clone()
220                            }
221                        })
222                        .unwrap_or_else(|| text_content.clone());
223                    messages.push(Message {
224                        role: MessageRole::Tool,
225                        content: content_value,
226                        tool_calls: None,
227                        tool_call_id,
228                    });
229                }
230                _ => {
231                    messages.push(Message::user(text_content));
232                }
233            }
234        }
235
236        if messages.is_empty() {
237            return None;
238        }
239
240        let tools = value.get("tools").and_then(|tools_value| {
241            let tools_array = tools_value.as_array()?;
242            let converted: Vec<_> = tools_array
243                .iter()
244                .filter_map(|tool| {
245                    let function = tool.get("function")?;
246                    let name = function.get("name").and_then(|n| n.as_str())?;
247                    let description = function
248                        .get("description")
249                        .and_then(|d| d.as_str())
250                        .unwrap_or("")
251                        .to_string();
252                    let parameters = function
253                        .get("parameters")
254                        .cloned()
255                        .unwrap_or_else(|| json!({}));
256                    Some(ToolDefinition::function(
257                        name.to_string(),
258                        description,
259                        parameters,
260                    ))
261                })
262                .collect();
263
264            if converted.is_empty() {
265                None
266            } else {
267                Some(converted)
268            }
269        });
270        let temperature = value
271            .get("temperature")
272            .and_then(|v| v.as_f64())
273            .map(|v| v as f32);
274        let max_tokens = value
275            .get("max_tokens")
276            .and_then(|v| v.as_u64())
277            .map(|v| v as u32);
278        let stream = value
279            .get("stream")
280            .and_then(|v| v.as_bool())
281            .unwrap_or(false);
282        let tool_choice = value.get("tool_choice").and_then(Self::parse_tool_choice);
283        let parallel_tool_calls = value.get("parallel_tool_calls").and_then(|v| v.as_bool());
284        let reasoning_effort = value
285            .get("reasoning_effort")
286            .and_then(|v| v.as_str())
287            .map(|s| s.to_string())
288            .or_else(|| {
289                value
290                    .get("reasoning")
291                    .and_then(|r| r.get("effort"))
292                    .and_then(|effort| effort.as_str())
293                    .map(|s| s.to_string())
294            });
295
296        let model = value
297            .get("model")
298            .and_then(|m| m.as_str())
299            .unwrap_or(&self.model)
300            .to_string();
301
302        Some(LLMRequest {
303            messages,
304            system_prompt,
305            tools,
306            model,
307            max_tokens,
308            temperature,
309            stream,
310            tool_choice,
311            parallel_tool_calls,
312            parallel_tool_config: None,
313            reasoning_effort,
314        })
315    }
316
317    fn extract_content_text(content: &Value) -> String {
318        match content {
319            Value::String(text) => text.to_string(),
320            Value::Array(parts) => parts
321                .iter()
322                .filter_map(|part| {
323                    if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
324                        Some(text.to_string())
325                    } else if let Some(Value::String(text)) = part.get("content") {
326                        Some(text.clone())
327                    } else {
328                        None
329                    }
330                })
331                .collect::<Vec<_>>()
332                .join(""),
333            _ => String::new(),
334        }
335    }
336
337    fn parse_tool_choice(choice: &Value) -> Option<ToolChoice> {
338        match choice {
339            Value::String(value) => match value.as_str() {
340                "auto" => Some(ToolChoice::auto()),
341                "none" => Some(ToolChoice::none()),
342                "required" => Some(ToolChoice::any()),
343                _ => None,
344            },
345            Value::Object(map) => {
346                let choice_type = map.get("type").and_then(|t| t.as_str())?;
347                match choice_type {
348                    "function" => map
349                        .get("function")
350                        .and_then(|f| f.get("name"))
351                        .and_then(|n| n.as_str())
352                        .map(|name| ToolChoice::function(name.to_string())),
353                    "auto" => Some(ToolChoice::auto()),
354                    "none" => Some(ToolChoice::none()),
355                    "any" | "required" => Some(ToolChoice::any()),
356                    _ => None,
357                }
358            }
359            _ => None,
360        }
361    }
362
363    fn convert_to_openai_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
364        let mut messages = Vec::new();
365
366        if let Some(system_prompt) = &request.system_prompt {
367            messages.push(json!({
368                "role": crate::config::constants::message_roles::SYSTEM,
369                "content": system_prompt
370            }));
371        }
372
373        for msg in &request.messages {
374            let role = msg.role.as_openai_str();
375            let mut message = json!({
376                "role": role,
377                "content": msg.content
378            });
379
380            if msg.role == MessageRole::Assistant {
381                if let Some(tool_calls) = &msg.tool_calls {
382                    if !tool_calls.is_empty() {
383                        let tool_calls_json: Vec<Value> = tool_calls
384                            .iter()
385                            .map(|tc| {
386                                json!({
387                                    "id": tc.id,
388                                    "type": "function",
389                                    "function": {
390                                        "name": tc.function.name,
391                                        "arguments": tc.function.arguments
392                                    }
393                                })
394                            })
395                            .collect();
396                        message["tool_calls"] = Value::Array(tool_calls_json);
397                    }
398                }
399            }
400
401            if msg.role == MessageRole::Tool {
402                if let Some(tool_call_id) = &msg.tool_call_id {
403                    message["tool_call_id"] = Value::String(tool_call_id.clone());
404                }
405            }
406
407            messages.push(message);
408        }
409
410        if messages.is_empty() {
411            let formatted_error = error_display::format_llm_error("OpenAI", "No messages provided");
412            return Err(LLMError::InvalidRequest(formatted_error));
413        }
414
415        let mut openai_request = json!({
416            "model": request.model,
417            "messages": messages,
418            "stream": request.stream
419        });
420
421        if let Some(max_tokens) = request.max_tokens {
422            if request.temperature.is_some() && Self::supports_temperature_parameter(&request.model)
423            {
424                if let Some(temperature) = request.temperature {
425                    openai_request["temperature"] = json!(temperature);
426                }
427            }
428            openai_request["max_tokens"] = json!(max_tokens);
429        }
430
431        if let Some(tools) = &request.tools {
432            if let Some(serialized) = Self::serialize_tools(tools) {
433                openai_request["tools"] = serialized;
434            }
435        }
436
437        if let Some(tool_choice) = &request.tool_choice {
438            openai_request["tool_choice"] = tool_choice.to_provider_format("openai");
439        }
440
441        if let Some(parallel) = request.parallel_tool_calls {
442            openai_request["parallel_tool_calls"] = Value::Bool(parallel);
443        }
444
445        if let Some(effort) = request.reasoning_effort.as_deref() {
446            if self.supports_reasoning_effort(&request.model) {
447                openai_request["reasoning"] = json!({ "effort": effort });
448            }
449        }
450
451        Ok(openai_request)
452    }
453
454    fn convert_to_openai_responses_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
455        let input = if Self::is_gpt5_codex_model(&request.model) {
456            build_codex_responses_input_openai(request)?
457        } else {
458            build_standard_responses_input_openai(request)?
459        };
460
461        if input.is_empty() {
462            let formatted_error =
463                error_display::format_llm_error("OpenAI", "No messages provided for Responses API");
464            return Err(LLMError::InvalidRequest(formatted_error));
465        }
466
467        let mut openai_request = json!({
468            "model": request.model,
469            "input": input,
470            "stream": request.stream
471        });
472
473        if let Some(max_tokens) = request.max_tokens {
474            if request.temperature.is_some() && Self::supports_temperature_parameter(&request.model)
475            {
476                if let Some(temperature) = request.temperature {
477                    openai_request["temperature"] = json!(temperature);
478                }
479            }
480            openai_request["max_output_tokens"] = json!(max_tokens);
481        }
482
483        if let Some(tools) = &request.tools {
484            if let Some(serialized) = Self::serialize_tools(tools) {
485                openai_request["tools"] = serialized;
486            }
487        }
488
489        if let Some(tool_choice) = &request.tool_choice {
490            openai_request["tool_choice"] = tool_choice.to_provider_format("openai");
491        }
492
493        if let Some(parallel) = request.parallel_tool_calls {
494            openai_request["parallel_tool_calls"] = Value::Bool(parallel);
495        }
496
497        if let Some(effort) = request.reasoning_effort.as_deref() {
498            if self.supports_reasoning_effort(&request.model) {
499                openai_request["reasoning"] = json!({ "effort": effort });
500            }
501        }
502
503        if Self::is_reasoning_model(&request.model) {
504            openai_request["reasoning"] = json!({ "effort": "medium" });
505        }
506
507        Ok(openai_request)
508    }
509
510    fn parse_openai_response(&self, response_json: Value) -> Result<LLMResponse, LLMError> {
511        let choices = response_json
512            .get("choices")
513            .and_then(|c| c.as_array())
514            .ok_or_else(|| {
515                let formatted_error = error_display::format_llm_error(
516                    "OpenAI",
517                    "Invalid response format: missing choices",
518                );
519                LLMError::Provider(formatted_error)
520            })?;
521
522        if choices.is_empty() {
523            let formatted_error =
524                error_display::format_llm_error("OpenAI", "No choices in response");
525            return Err(LLMError::Provider(formatted_error));
526        }
527
528        let choice = &choices[0];
529        let message = choice.get("message").ok_or_else(|| {
530            let formatted_error = error_display::format_llm_error(
531                "OpenAI",
532                "Invalid response format: missing message",
533            );
534            LLMError::Provider(formatted_error)
535        })?;
536
537        let content = match message.get("content") {
538            Some(Value::String(text)) => Some(text.to_string()),
539            Some(Value::Array(parts)) => {
540                let text = parts
541                    .iter()
542                    .filter_map(|part| part.get("text").and_then(|t| t.as_str()))
543                    .collect::<Vec<_>>()
544                    .join("");
545                if text.is_empty() { None } else { Some(text) }
546            }
547            _ => None,
548        };
549
550        let tool_calls = message
551            .get("tool_calls")
552            .and_then(|tc| tc.as_array())
553            .map(|calls| {
554                calls
555                    .iter()
556                    .filter_map(|call| {
557                        let id = call.get("id").and_then(|v| v.as_str())?;
558                        let function = call.get("function")?;
559                        let name = function.get("name").and_then(|v| v.as_str())?;
560                        let arguments = function.get("arguments");
561                        let serialized = arguments.map_or("{}".to_string(), |value| {
562                            if value.is_string() {
563                                value.as_str().unwrap_or("").to_string()
564                            } else {
565                                value.to_string()
566                            }
567                        });
568                        Some(ToolCall::function(
569                            id.to_string(),
570                            name.to_string(),
571                            serialized,
572                        ))
573                    })
574                    .collect::<Vec<_>>()
575            })
576            .filter(|calls| !calls.is_empty());
577
578        let reasoning = message
579            .get("reasoning")
580            .and_then(extract_reasoning_trace)
581            .or_else(|| choice.get("reasoning").and_then(extract_reasoning_trace));
582
583        let finish_reason = choice
584            .get("finish_reason")
585            .and_then(|fr| fr.as_str())
586            .map(|fr| match fr {
587                "stop" => FinishReason::Stop,
588                "length" => FinishReason::Length,
589                "tool_calls" => FinishReason::ToolCalls,
590                "content_filter" => FinishReason::ContentFilter,
591                other => FinishReason::Error(other.to_string()),
592            })
593            .unwrap_or(FinishReason::Stop);
594
595        Ok(LLMResponse {
596            content,
597            tool_calls,
598            usage: response_json.get("usage").map(|usage_value| {
599                let cached_prompt_tokens =
600                    if self.prompt_cache_enabled && self.prompt_cache_settings.surface_metrics {
601                        usage_value
602                            .get("prompt_tokens_details")
603                            .and_then(|details| details.get("cached_tokens"))
604                            .and_then(|value| value.as_u64())
605                            .map(|value| value as u32)
606                    } else {
607                        None
608                    };
609
610                crate::llm::provider::Usage {
611                    prompt_tokens: usage_value
612                        .get("prompt_tokens")
613                        .and_then(|pt| pt.as_u64())
614                        .unwrap_or(0) as u32,
615                    completion_tokens: usage_value
616                        .get("completion_tokens")
617                        .and_then(|ct| ct.as_u64())
618                        .unwrap_or(0) as u32,
619                    total_tokens: usage_value
620                        .get("total_tokens")
621                        .and_then(|tt| tt.as_u64())
622                        .unwrap_or(0) as u32,
623                    cached_prompt_tokens,
624                    cache_creation_tokens: None,
625                    cache_read_tokens: None,
626                }
627            }),
628            finish_reason,
629            reasoning,
630        })
631    }
632
633    fn parse_openai_responses_response(
634        &self,
635        response_json: Value,
636    ) -> Result<LLMResponse, LLMError> {
637        let output = response_json
638            .get("output")
639            .or_else(|| response_json.get("choices"))
640            .and_then(|value| value.as_array())
641            .ok_or_else(|| {
642                let formatted_error = error_display::format_llm_error(
643                    "OpenAI",
644                    "Invalid response format: missing output",
645                );
646                LLMError::Provider(formatted_error)
647            })?;
648
649        if output.is_empty() {
650            let formatted_error =
651                error_display::format_llm_error("OpenAI", "No output in response");
652            return Err(LLMError::Provider(formatted_error));
653        }
654
655        let mut content_fragments = Vec::new();
656        let mut reasoning_fragments = Vec::new();
657        let mut tool_calls_vec = Vec::new();
658
659        for item in output {
660            let item_type = item
661                .get("type")
662                .and_then(|value| value.as_str())
663                .unwrap_or("");
664            if item_type != "message" {
665                continue;
666            }
667
668            if let Some(content_array) = item.get("content").and_then(|value| value.as_array()) {
669                for entry in content_array {
670                    let entry_type = entry
671                        .get("type")
672                        .and_then(|value| value.as_str())
673                        .unwrap_or("");
674                    match entry_type {
675                        "output_text" | "text" => {
676                            if let Some(text) = entry.get("text").and_then(|value| value.as_str()) {
677                                if !text.is_empty() {
678                                    content_fragments.push(text.to_string());
679                                }
680                            }
681                        }
682                        "reasoning" => {
683                            if let Some(text) = entry.get("text").and_then(|value| value.as_str()) {
684                                if !text.is_empty() {
685                                    reasoning_fragments.push(text.to_string());
686                                }
687                            }
688                        }
689                        "tool_call" => {
690                            let (name_value, arguments_value) = if let Some(function) =
691                                entry.get("function").and_then(|value| value.as_object())
692                            {
693                                let name = function.get("name").and_then(|value| value.as_str());
694                                let arguments = function.get("arguments");
695                                (name, arguments)
696                            } else {
697                                let name = entry.get("name").and_then(|value| value.as_str());
698                                let arguments = entry.get("arguments");
699                                (name, arguments)
700                            };
701
702                            if let Some(name) = name_value {
703                                let id = entry
704                                    .get("id")
705                                    .and_then(|value| value.as_str())
706                                    .unwrap_or_else(|| "");
707                                let serialized =
708                                    arguments_value.map_or("{}".to_string(), |value| {
709                                        if value.is_string() {
710                                            value.as_str().unwrap_or("").to_string()
711                                        } else {
712                                            value.to_string()
713                                        }
714                                    });
715                                tool_calls_vec.push(ToolCall::function(
716                                    id.to_string(),
717                                    name.to_string(),
718                                    serialized,
719                                ));
720                            }
721                        }
722                        _ => {}
723                    }
724                }
725            }
726        }
727
728        let content = if content_fragments.is_empty() {
729            None
730        } else {
731            Some(content_fragments.join(""))
732        };
733
734        let reasoning = if reasoning_fragments.is_empty() {
735            None
736        } else {
737            Some(reasoning_fragments.join(""))
738        };
739
740        let tool_calls = if tool_calls_vec.is_empty() {
741            None
742        } else {
743            Some(tool_calls_vec)
744        };
745
746        let usage = response_json.get("usage").map(|usage_value| {
747            let cached_prompt_tokens =
748                if self.prompt_cache_enabled && self.prompt_cache_settings.surface_metrics {
749                    usage_value
750                        .get("prompt_tokens_details")
751                        .and_then(|details| details.get("cached_tokens"))
752                        .or_else(|| usage_value.get("prompt_cache_hit_tokens"))
753                        .and_then(|value| value.as_u64())
754                        .map(|value| value as u32)
755                } else {
756                    None
757                };
758
759            crate::llm::provider::Usage {
760                prompt_tokens: usage_value
761                    .get("input_tokens")
762                    .or_else(|| usage_value.get("prompt_tokens"))
763                    .and_then(|pt| pt.as_u64())
764                    .unwrap_or(0) as u32,
765                completion_tokens: usage_value
766                    .get("output_tokens")
767                    .or_else(|| usage_value.get("completion_tokens"))
768                    .and_then(|ct| ct.as_u64())
769                    .unwrap_or(0) as u32,
770                total_tokens: usage_value
771                    .get("total_tokens")
772                    .and_then(|tt| tt.as_u64())
773                    .unwrap_or(0) as u32,
774                cached_prompt_tokens,
775                cache_creation_tokens: None,
776                cache_read_tokens: None,
777            }
778        });
779
780        let stop_reason = response_json
781            .get("stop_reason")
782            .and_then(|value| value.as_str())
783            .or_else(|| {
784                output
785                    .iter()
786                    .find_map(|item| item.get("stop_reason").and_then(|value| value.as_str()))
787            })
788            .unwrap_or("stop");
789
790        let finish_reason = match stop_reason {
791            "stop" => FinishReason::Stop,
792            "max_output_tokens" | "length" => FinishReason::Length,
793            "tool_use" | "tool_calls" => FinishReason::ToolCalls,
794            other => FinishReason::Error(other.to_string()),
795        };
796
797        Ok(LLMResponse {
798            content,
799            tool_calls,
800            usage,
801            finish_reason,
802            reasoning,
803        })
804    }
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    fn sample_tool() -> ToolDefinition {
812        ToolDefinition::function(
813            "search_workspace".to_string(),
814            "Search project files".to_string(),
815            json!({
816                "type": "object",
817                "properties": {
818                    "query": {"type": "string"}
819                },
820                "required": ["query"],
821                "additionalProperties": false
822            }),
823        )
824    }
825
826    fn sample_request(model: &str) -> LLMRequest {
827        LLMRequest {
828            messages: vec![Message::user("Hello".to_string())],
829            system_prompt: None,
830            tools: Some(vec![sample_tool()]),
831            model: model.to_string(),
832            max_tokens: None,
833            temperature: None,
834            stream: false,
835            tool_choice: None,
836            parallel_tool_calls: None,
837            parallel_tool_config: None,
838            reasoning_effort: None,
839        }
840    }
841
842    #[test]
843    fn serialize_tools_wraps_function_definition() {
844        let tools = vec![sample_tool()];
845        let serialized = OpenAIProvider::serialize_tools(&tools).expect("tools should serialize");
846        let serialized_tools = serialized
847            .as_array()
848            .expect("serialized tools should be an array");
849        assert_eq!(serialized_tools.len(), 1);
850
851        let tool_value = serialized_tools[0]
852            .as_object()
853            .expect("tool should be serialized as object");
854        assert_eq!(
855            tool_value.get("type").and_then(Value::as_str),
856            Some("function")
857        );
858        assert!(tool_value.contains_key("function"));
859        assert!(!tool_value.contains_key("name"));
860
861        let function_value = tool_value
862            .get("function")
863            .and_then(Value::as_object)
864            .expect("function payload missing");
865        assert_eq!(
866            function_value.get("name").and_then(Value::as_str),
867            Some("search_workspace")
868        );
869        assert!(function_value.contains_key("parameters"));
870    }
871
872    #[test]
873    fn chat_completions_payload_uses_function_wrapper() {
874        let provider =
875            OpenAIProvider::with_model(String::new(), models::openai::DEFAULT_MODEL.to_string());
876        let request = sample_request(models::openai::DEFAULT_MODEL);
877        let payload = provider
878            .convert_to_openai_format(&request)
879            .expect("conversion should succeed");
880
881        let tools = payload
882            .get("tools")
883            .and_then(Value::as_array)
884            .expect("tools should exist on payload");
885        let tool_object = tools[0].as_object().expect("tool entry should be object");
886        assert!(tool_object.contains_key("function"));
887        assert!(!tool_object.contains_key("name"));
888    }
889
890    #[test]
891    fn responses_payload_uses_function_wrapper() {
892        let provider =
893            OpenAIProvider::with_model(String::new(), models::openai::GPT_5_CODEX.to_string());
894        let request = sample_request(models::openai::GPT_5_CODEX);
895        let payload = provider
896            .convert_to_openai_responses_format(&request)
897            .expect("conversion should succeed");
898
899        let tools = payload
900            .get("tools")
901            .and_then(Value::as_array)
902            .expect("tools should exist on payload");
903        let tool_object = tools[0].as_object().expect("tool entry should be object");
904        assert!(tool_object.contains_key("function"));
905        assert!(!tool_object.contains_key("name"));
906    }
907}
908
909fn build_standard_responses_input_openai(request: &LLMRequest) -> Result<Vec<Value>, LLMError> {
910    let mut input = Vec::new();
911
912    if let Some(system_prompt) = &request.system_prompt {
913        if !system_prompt.trim().is_empty() {
914            input.push(json!({
915                "role": "developer",
916                "content": [{
917                    "type": "input_text",
918                    "text": system_prompt.clone()
919                }]
920            }));
921        }
922    }
923
924    for msg in &request.messages {
925        match msg.role {
926            MessageRole::System => {
927                if !msg.content.trim().is_empty() {
928                    input.push(json!({
929                        "role": "developer",
930                        "content": [{
931                            "type": "input_text",
932                            "text": msg.content.clone()
933                        }]
934                    }));
935                }
936            }
937            MessageRole::User => {
938                input.push(json!({
939                    "role": "user",
940                    "content": [{
941                        "type": "input_text",
942                        "text": msg.content.clone()
943                    }]
944                }));
945            }
946            MessageRole::Assistant => {
947                let mut content_parts = Vec::new();
948                if !msg.content.is_empty() {
949                    content_parts.push(json!({
950                        "type": "output_text",
951                        "text": msg.content.clone()
952                    }));
953                }
954
955                if let Some(tool_calls) = &msg.tool_calls {
956                    for call in tool_calls {
957                        content_parts.push(json!({
958                            "type": "tool_call",
959                            "id": call.id.clone(),
960                            "function": {
961                                "name": call.function.name.clone(),
962                                "arguments": call.function.arguments.clone()
963                            }
964                        }));
965                    }
966                }
967
968                if !content_parts.is_empty() {
969                    input.push(json!({
970                        "role": "assistant",
971                        "content": content_parts
972                    }));
973                }
974            }
975            MessageRole::Tool => {
976                let tool_call_id = msg.tool_call_id.clone().ok_or_else(|| {
977                    let formatted_error = error_display::format_llm_error(
978                        "OpenAI",
979                        "Tool messages must include tool_call_id for Responses API",
980                    );
981                    LLMError::InvalidRequest(formatted_error)
982                })?;
983
984                let mut tool_content = Vec::new();
985                if !msg.content.trim().is_empty() {
986                    tool_content.push(json!({
987                        "type": "output_text",
988                        "text": msg.content.clone()
989                    }));
990                }
991
992                let mut tool_result = json!({
993                    "type": "tool_result",
994                    "tool_call_id": tool_call_id
995                });
996
997                if !tool_content.is_empty() {
998                    if let Value::Object(ref mut map) = tool_result {
999                        map.insert("content".to_string(), json!(tool_content));
1000                    }
1001                }
1002
1003                input.push(json!({
1004                    "role": "tool",
1005                    "content": [tool_result]
1006                }));
1007            }
1008        }
1009    }
1010
1011    Ok(input)
1012}
1013
1014fn build_codex_responses_input_openai(request: &LLMRequest) -> Result<Vec<Value>, LLMError> {
1015    let mut additional_guidance = Vec::new();
1016
1017    if let Some(system_prompt) = &request.system_prompt {
1018        let trimmed = system_prompt.trim();
1019        if !trimmed.is_empty() {
1020            additional_guidance.push(trimmed.to_string());
1021        }
1022    }
1023
1024    let mut input = Vec::new();
1025
1026    for msg in &request.messages {
1027        match msg.role {
1028            MessageRole::System => {
1029                let trimmed = msg.content.trim();
1030                if !trimmed.is_empty() {
1031                    additional_guidance.push(trimmed.to_string());
1032                }
1033            }
1034            MessageRole::User => {
1035                input.push(json!({
1036                    "role": "user",
1037                    "content": [{
1038                        "type": "input_text",
1039                        "text": msg.content.clone()
1040                    }]
1041                }));
1042            }
1043            MessageRole::Assistant => {
1044                let mut content_parts = Vec::new();
1045                if !msg.content.is_empty() {
1046                    content_parts.push(json!({
1047                        "type": "output_text",
1048                        "text": msg.content.clone()
1049                    }));
1050                }
1051
1052                if let Some(tool_calls) = &msg.tool_calls {
1053                    for call in tool_calls {
1054                        content_parts.push(json!({
1055                            "type": "tool_call",
1056                            "id": call.id.clone(),
1057                            "function": {
1058                                "name": call.function.name.clone(),
1059                                "arguments": call.function.arguments.clone()
1060                            }
1061                        }));
1062                    }
1063                }
1064
1065                if !content_parts.is_empty() {
1066                    input.push(json!({
1067                        "role": "assistant",
1068                        "content": content_parts
1069                    }));
1070                }
1071            }
1072            MessageRole::Tool => {
1073                let tool_call_id = msg.tool_call_id.clone().ok_or_else(|| {
1074                    let formatted_error = error_display::format_llm_error(
1075                        "OpenAI",
1076                        "Tool messages must include tool_call_id for Responses API",
1077                    );
1078                    LLMError::InvalidRequest(formatted_error)
1079                })?;
1080
1081                let mut tool_content = Vec::new();
1082                if !msg.content.trim().is_empty() {
1083                    tool_content.push(json!({
1084                        "type": "output_text",
1085                        "text": msg.content.clone()
1086                    }));
1087                }
1088
1089                let mut tool_result = json!({
1090                    "type": "tool_result",
1091                    "tool_call_id": tool_call_id
1092                });
1093
1094                if !tool_content.is_empty() {
1095                    if let Value::Object(ref mut map) = tool_result {
1096                        map.insert("content".to_string(), json!(tool_content));
1097                    }
1098                }
1099
1100                input.push(json!({
1101                    "role": "tool",
1102                    "content": [tool_result]
1103                }));
1104            }
1105        }
1106    }
1107
1108    let developer_prompt = gpt5_codex_developer_prompt(&additional_guidance);
1109    input.insert(
1110        0,
1111        json!({
1112            "role": "developer",
1113            "content": [{
1114                "type": "input_text",
1115                "text": developer_prompt
1116            }]
1117        }),
1118    );
1119
1120    Ok(input)
1121}
1122
1123#[async_trait]
1124impl LLMProvider for OpenAIProvider {
1125    fn name(&self) -> &str {
1126        "openai"
1127    }
1128
1129    fn supports_reasoning(&self, _model: &str) -> bool {
1130        false
1131    }
1132
1133    fn supports_reasoning_effort(&self, model: &str) -> bool {
1134        let requested = if model.trim().is_empty() {
1135            self.model.as_str()
1136        } else {
1137            model
1138        };
1139        models::openai::REASONING_MODELS
1140            .iter()
1141            .any(|candidate| *candidate == requested)
1142    }
1143
1144    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
1145        let mut request = request;
1146        if request.model.trim().is_empty() {
1147            request.model = self.model.clone();
1148        }
1149
1150        if Self::uses_responses_api(&request.model) {
1151            let openai_request = self.convert_to_openai_responses_format(&request)?;
1152            let url = format!("{}/responses", self.base_url);
1153
1154            let response = self
1155                .http_client
1156                .post(&url)
1157                .bearer_auth(&self.api_key)
1158                .json(&openai_request)
1159                .send()
1160                .await
1161                .map_err(|e| {
1162                    let formatted_error =
1163                        error_display::format_llm_error("OpenAI", &format!("Network error: {}", e));
1164                    LLMError::Network(formatted_error)
1165                })?;
1166
1167            if !response.status().is_success() {
1168                let status = response.status();
1169                let error_text = response.text().await.unwrap_or_default();
1170
1171                if status.as_u16() == 429
1172                    || error_text.contains("insufficient_quota")
1173                    || error_text.contains("quota")
1174                    || error_text.contains("rate limit")
1175                {
1176                    return Err(LLMError::RateLimit);
1177                }
1178
1179                let formatted_error = error_display::format_llm_error(
1180                    "OpenAI",
1181                    &format!("HTTP {}: {}", status, error_text),
1182                );
1183                return Err(LLMError::Provider(formatted_error));
1184            }
1185
1186            let openai_response: Value = response.json().await.map_err(|e| {
1187                let formatted_error = error_display::format_llm_error(
1188                    "OpenAI",
1189                    &format!("Failed to parse response: {}", e),
1190                );
1191                LLMError::Provider(formatted_error)
1192            })?;
1193
1194            self.parse_openai_responses_response(openai_response)
1195        } else {
1196            let openai_request = self.convert_to_openai_format(&request)?;
1197            let url = format!("{}/chat/completions", self.base_url);
1198
1199            let response = self
1200                .http_client
1201                .post(&url)
1202                .bearer_auth(&self.api_key)
1203                .json(&openai_request)
1204                .send()
1205                .await
1206                .map_err(|e| {
1207                    let formatted_error =
1208                        error_display::format_llm_error("OpenAI", &format!("Network error: {}", e));
1209                    LLMError::Network(formatted_error)
1210                })?;
1211
1212            if !response.status().is_success() {
1213                let status = response.status();
1214                let error_text = response.text().await.unwrap_or_default();
1215
1216                if status.as_u16() == 429
1217                    || error_text.contains("insufficient_quota")
1218                    || error_text.contains("quota")
1219                    || error_text.contains("rate limit")
1220                {
1221                    return Err(LLMError::RateLimit);
1222                }
1223
1224                let formatted_error = error_display::format_llm_error(
1225                    "OpenAI",
1226                    &format!("HTTP {}: {}", status, error_text),
1227                );
1228                return Err(LLMError::Provider(formatted_error));
1229            }
1230
1231            let openai_response: Value = response.json().await.map_err(|e| {
1232                let formatted_error = error_display::format_llm_error(
1233                    "OpenAI",
1234                    &format!("Failed to parse response: {}", e),
1235                );
1236                LLMError::Provider(formatted_error)
1237            })?;
1238
1239            self.parse_openai_response(openai_response)
1240        }
1241    }
1242
1243    fn supported_models(&self) -> Vec<String> {
1244        models::openai::SUPPORTED_MODELS
1245            .iter()
1246            .map(|s| s.to_string())
1247            .collect()
1248    }
1249
1250    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
1251        if request.messages.is_empty() {
1252            let formatted_error =
1253                error_display::format_llm_error("OpenAI", "Messages cannot be empty");
1254            return Err(LLMError::InvalidRequest(formatted_error));
1255        }
1256
1257        if !self.supported_models().contains(&request.model) {
1258            let formatted_error = error_display::format_llm_error(
1259                "OpenAI",
1260                &format!("Unsupported model: {}", request.model),
1261            );
1262            return Err(LLMError::InvalidRequest(formatted_error));
1263        }
1264
1265        for message in &request.messages {
1266            if let Err(err) = message.validate_for_provider("openai") {
1267                let formatted = error_display::format_llm_error("OpenAI", &err);
1268                return Err(LLMError::InvalidRequest(formatted));
1269            }
1270        }
1271
1272        Ok(())
1273    }
1274}
1275
1276#[async_trait]
1277impl LLMClient for OpenAIProvider {
1278    async fn generate(&mut self, prompt: &str) -> Result<llm_types::LLMResponse, LLMError> {
1279        let request = self.parse_client_prompt(prompt);
1280        let request_model = request.model.clone();
1281        let response = LLMProvider::generate(self, request).await?;
1282
1283        Ok(llm_types::LLMResponse {
1284            content: response.content.unwrap_or_default(),
1285            model: request_model,
1286            usage: response.usage.map(|u| llm_types::Usage {
1287                prompt_tokens: u.prompt_tokens as usize,
1288                completion_tokens: u.completion_tokens as usize,
1289                total_tokens: u.total_tokens as usize,
1290                cached_prompt_tokens: u.cached_prompt_tokens.map(|v| v as usize),
1291                cache_creation_tokens: u.cache_creation_tokens.map(|v| v as usize),
1292                cache_read_tokens: u.cache_read_tokens.map(|v| v as usize),
1293            }),
1294            reasoning: response.reasoning,
1295        })
1296    }
1297
1298    fn backend_kind(&self) -> llm_types::BackendKind {
1299        llm_types::BackendKind::OpenAI
1300    }
1301
1302    fn model_id(&self) -> &str {
1303        &self.model
1304    }
1305}