Skip to main content

openresponses_rust/
client.rs

1use reqwest::{Client as ReqwestClient, header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}};
2use serde_json;
3use thiserror::Error;
4
5use crate::types::{CreateResponseBody, ResponseResource};
6
7const DEFAULT_BASE_URL: &str = "https://api.openai.com";
8
9#[derive(Error, Debug)]
10pub enum ClientError {
11    #[error("HTTP request failed: {0}")]
12    HttpError(#[from] reqwest::Error),
13    
14    #[error("JSON parsing error: {0}")]
15    JsonError(#[from] serde_json::Error),
16    
17    #[error("API error: {code} - {message}")]
18    ApiError { code: String, message: String },
19    
20    #[error("Invalid header value: {0}")]
21    InvalidHeader(String),
22}
23
24pub struct ClientBuilder {
25    api_key: String,
26    base_url: Option<String>,
27}
28
29impl ClientBuilder {
30    pub fn new(api_key: impl Into<String>) -> Self {
31        Self {
32            api_key: api_key.into(),
33            base_url: None,
34        }
35    }
36
37    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
38        self.base_url = Some(base_url.into());
39        self
40    }
41
42    pub fn build(self) -> Client {
43        let mut base_url = self.base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
44        
45        // Remove trailing slash if present
46        if base_url.ends_with('/') {
47            base_url.pop();
48        }
49        
50        // Automatically append /v1 if it's not present in the path
51        if !base_url.ends_with("/v1") {
52            base_url.push_str("/v1");
53        }
54        
55        let mut headers = HeaderMap::new();
56        headers.insert(
57            CONTENT_TYPE,
58            HeaderValue::from_static("application/json"),
59        );
60        
61        let inner = ReqwestClient::builder()
62            .default_headers(headers)
63            .build()
64            .expect("Failed to create HTTP client");
65        
66        Client {
67            inner,
68            base_url,
69            api_key: self.api_key,
70        }
71    }
72}
73
74#[derive(Clone)]
75pub struct Client {
76    inner: ReqwestClient,
77    base_url: String,
78    api_key: String,
79}
80
81impl Client {
82    pub fn new(api_key: impl Into<String>) -> Self {
83        ClientBuilder::new(api_key).build()
84    }
85    
86    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
87        ClientBuilder::new(api_key)
88    }
89
90    pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
91        ClientBuilder::new(api_key).base_url(base_url).build()
92    }
93    
94    pub async fn create_response(&self, request: CreateResponseBody) -> Result<ResponseResource, ClientError> {
95        let url = format!("{}/responses", self.base_url);
96        
97        let response = self.inner
98            .post(&url)
99            .header(AUTHORIZATION, format!("Bearer {}", self.api_key))
100            .json(&request)
101            .send()
102            .await?;
103        
104        let status = response.status();
105        
106        if !status.is_success() {
107            let error_text = response.text().await?;
108            return Err(ClientError::ApiError {
109                code: status.to_string(),
110                message: error_text,
111            });
112        }
113        
114        let response_body = response.json::<ResponseResource>().await?;
115        Ok(response_body)
116    }
117    
118    pub async fn create_response_raw(&self, request: CreateResponseBody) -> Result<String, ClientError> {
119        let url = format!("{}/responses", self.base_url);
120        
121        let response = self.inner
122            .post(&url)
123            .header(AUTHORIZATION, format!("Bearer {}", self.api_key))
124            .json(&request)
125            .send()
126            .await?;
127        
128        let status = response.status();
129        let body = response.text().await?;
130        
131        if !status.is_success() {
132            return Err(ClientError::ApiError {
133                code: status.to_string(),
134                message: body,
135            });
136        }
137        
138        Ok(body)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::types::{Input, Item};
146    
147    #[test]
148    fn test_client_creation() {
149        let client = Client::new("test-api-key");
150        assert_eq!(client.api_key, "test-api-key");
151        assert_eq!(client.base_url, "https://api.openai.com/v1");
152    }
153    
154    #[test]
155    fn test_client_with_base_url_normalization() {
156        // Domain only
157        let client = Client::with_base_url("test-key", "https://openrouter.ai/api");
158        assert_eq!(client.base_url, "https://openrouter.ai/api/v1");
159        
160        // Already includes v1
161        let client = Client::with_base_url("test-key", "https://openrouter.ai/api/v1");
162        assert_eq!(client.base_url, "https://openrouter.ai/api/v1");
163
164        // Localhost
165        let client = Client::with_base_url("test-key", "http://localhost:1234");
166        assert_eq!(client.base_url, "http://localhost:1234/v1");
167    }
168    
169    #[tokio::test]
170    async fn test_request_serialization() {
171        let request = CreateResponseBody {
172            model: Some("gpt-4o".to_string()),
173            input: Some(Input::Items(vec![
174                Item::user_message("Hello, world!")
175            ])),
176            ..Default::default()
177        };
178        
179        let json = serde_json::to_string(&request).unwrap();
180        assert!(json.contains("gpt-4o"));
181        assert!(json.contains("Hello, world!"));
182    }
183}