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 !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}