doum_cli/llm/openai/
client.rs

1use crate::llm::client::{LLMClient, LLMRequest};
2use crate::llm::openai::payloads::{
3    OpenAIConfig, OpenAIError, OpenAIOutput, OpenAIRequest, OpenAIResponse, OpenAIWebSearchTool,
4};
5use crate::system::error::{DoumError, DoumResult};
6use reqwest::Client;
7use std::time::Duration;
8
9/// OpenAI LLM Client
10pub struct OpenAIClient {
11    http_client: Client,
12    config: OpenAIConfig,
13}
14
15impl OpenAIClient {
16    /// OpenAI API URL
17    const API_URL: &'static str = "https://api.openai.com/v1/responses";
18
19    /// Create a new OpenAIClient
20    pub fn new(config: OpenAIConfig, timeout: u64) -> DoumResult<Self> {
21        if config.api_key.is_empty() {
22            return Err(DoumError::InvalidConfig(
23                "OpenAI API key is not set. Please configure it in the interactive config menu (doum config).".to_string()
24            ));
25        }
26
27        let http_client = Client::builder()
28            .timeout(Duration::from_secs(timeout))
29            .build()
30            .map_err(|e| DoumError::LLM(format!("Failed to build HTTP client: {}", e)))?;
31
32        Ok(Self {
33            http_client,
34            config,
35        })
36    }
37}
38
39#[async_trait::async_trait]
40impl LLMClient for OpenAIClient {
41    async fn generate(&self, request: LLMRequest) -> DoumResult<String> {
42        // create OpenAI request payload
43        let openai_request = OpenAIRequest {
44            model: self.config.model.clone(),
45            instructions: Some(request.system),
46            input: request.messages,
47            tools: vec![OpenAIWebSearchTool {
48                tool_type: "web_search".to_string(),
49            }]
50            .into(),
51        };
52
53        // build request with headers
54        let mut builder = self
55            .http_client
56            .post(Self::API_URL)
57            .header("Authorization", format!("Bearer {}", self.config.api_key))
58            .header("Content-Type", "application/json");
59
60        // Optional headers
61        if let Some(ref org) = self.config.organization {
62            builder = builder.header("OpenAI-Organization", org);
63        }
64        if let Some(ref proj) = self.config.project {
65            builder = builder.header("OpenAI-Project", proj);
66        }
67
68        // send request
69        let response = builder.json(&openai_request).send().await.map_err(|e| {
70            if e.is_timeout() {
71                DoumError::Timeout
72            } else if e.is_connect() {
73                DoumError::LLM("Failed to connect to OpenAI API".to_string())
74            } else {
75                DoumError::LLM(format!("Failed to send request to OpenAI API: {}", e))
76            }
77        })?;
78
79        // check response status
80        let status = response.status();
81
82        if !status.is_success() {
83            let error_text = response
84                .text()
85                .await
86                .unwrap_or_else(|_| "Unknown error".to_string());
87
88            // Try to parse OpenAI error format
89            if let Ok(openai_error) = serde_json::from_str::<OpenAIError>(&error_text) {
90                return Err(DoumError::LLM(format!(
91                    "OpenAI API Error ({}): {}",
92                    status, openai_error.error.message
93                )));
94            }
95
96            return Err(DoumError::LLM(format!(
97                "OpenAI API Error: {} - {}",
98                status, error_text
99            )));
100        }
101
102        // Parse response body
103        let openai_response: OpenAIResponse = response
104            .json()
105            .await
106            .map_err(|e| DoumError::Parse(format!("Failed to parse OpenAI response: {}", e)))?;
107
108        // Extract message content
109        for output in openai_response.output {
110            if let OpenAIOutput::Message { content } = output
111                && let Some(first_content) = content.first()
112            {
113                return Ok(first_content.text.clone());
114            }
115        }
116
117        Err(DoumError::Parse(
118            "No content in OpenAI response".to_string(),
119        ))
120    }
121}