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