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