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