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 anyhow::{Context, Result};
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) -> Result<Self> {
21        if config.api_key.is_empty() {
22            anyhow::bail!(
23                "OpenAI API key is not set. Please configure it in the interactive config menu (doum config)."
24            );
25        }
26
27        let http_client = Client::builder()
28            .timeout(Duration::from_secs(timeout))
29            .build()
30            .context("Failed to build HTTP client")?;
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) -> Result<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                anyhow::anyhow!("Request timeout")
72            } else if e.is_connect() {
73                anyhow::anyhow!("Failed to connect to OpenAI API")
74            } else {
75                anyhow::anyhow!("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                anyhow::bail!(
91                    "OpenAI API Error ({}): {}",
92                    status,
93                    openai_error.error.message
94                );
95            }
96
97            anyhow::bail!("OpenAI API Error: {} - {}", status, error_text);
98        }
99
100        // Parse response body
101        let openai_response: OpenAIResponse = response
102            .json()
103            .await
104            .context("Failed to parse OpenAI response")?;
105
106        // Extract message content
107        for output in openai_response.output {
108            if let OpenAIOutput::Message { content } = output
109                && let Some(first_content) = content.first()
110            {
111                return Ok(first_content.text.clone());
112            }
113        }
114
115        anyhow::bail!("No content in OpenAI response")
116    }
117}