doum_cli/llm/openai/
client.rs

1use crate::system::error::{DoumError, Result};
2use crate::llm::client::{LLMClient, LLMRequest};
3use crate::llm::openai::payloads::{
4    OpenAIConfig, OpenAIRequest, OpenAIResponse, OpenAIOutput, OpenAIError, OpenAIWebSearchTool,
5};
6use reqwest::Client;
7use std::time::Duration;
8
9/// OpenAI 클라이언트
10pub struct OpenAIClient {
11    http_client: Client,
12    config: OpenAIConfig,
13}
14
15impl OpenAIClient {
16    /// OpenAI API 엔드포인트
17    const API_URL: &'static str = "https://api.openai.com/v1/responses";
18    
19    /// 새 OpenAI 클라이언트 생성
20    pub fn new(config: OpenAIConfig, timeout: u64) -> Result<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!("HTTP 클라이언트 생성 실패: {}", 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) -> Result<String> {
42        // OpenAI API 요청 생성
43        let openai_request = OpenAIRequest {
44            model: self.config.model.clone(),
45            instructions: Some(request.system),
46            input: request.messages,
47            tools: vec![
48                OpenAIWebSearchTool {
49                    tool_type: "web_search".to_string(),
50                }
51            ].into(),
52        };
53
54        // API 요청 빌더
55        let mut builder = self.http_client
56            .post(Self::API_URL)
57            .header("Authorization", format!("Bearer {}", self.config.api_key))
58            .header("Content-Type", "application/json");
59        
60        // 선택적 헤더 추가
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        let response = builder
69            .json(&openai_request)
70            .send()
71            .await
72            .map_err(|e| {
73                if e.is_timeout() {
74                    DoumError::Timeout
75                } else if e.is_connect() {
76                    DoumError::LLM("네트워크 연결 실패. 인터넷 연결을 확인하세요.".to_string())
77                } else {
78                    DoumError::LLM(format!("API 요청 실패: {}", e))
79                }
80            })?;
81
82        // HTTP 상태 코드 확인
83        let status = response.status();
84        
85        if !status.is_success() {
86            let error_text = response.text().await
87                .unwrap_or_else(|_| "알 수 없는 에러".to_string());
88            
89            // OpenAI 에러 응답 파싱 시도
90            if let Ok(openai_error) = serde_json::from_str::<OpenAIError>(&error_text) {
91                return Err(DoumError::LLM(format!(
92                    "OpenAI API 에러 ({}): {}",
93                    status,
94                    openai_error.error.message
95                )));
96            }
97            
98            return Err(DoumError::LLM(format!(
99                "API 요청 실패 ({}): {}",
100                status,
101                error_text
102            )));
103        }
104
105        // 응답 본문을 먼저 텍스트로 읽어서 로깅
106        let openai_response: OpenAIResponse = response.json().await
107            .map_err(|e| DoumError::Parse(format!("응답 본문 읽기 실패: {}", e)))?;
108
109        // message 타입의 output에서 content 추출
110        for output in openai_response.output {
111            if let OpenAIOutput::Message { content } = output
112                && let Some(first_content) = content.first() {
113                    return Ok(first_content.text.clone());
114                }
115        }
116        
117        Err(DoumError::Parse(
118            "API 응답에 메시지 내용이 없습니다".to_string()
119        ))
120    }
121}