Skip to main content

mailrs_intelligence/
openai_compatible.rs

1use async_trait::async_trait;
2
3use crate::provider::LlmProvider;
4
5/// Reference [`LlmProvider`] backed by an OpenAI-compatible HTTP endpoint.
6///
7/// Concretely this is what mailrs uses against its self-hosted qwen3.5-9b
8/// server — but any service that accepts the same `POST {system, messages,
9/// temperature}` shape works. The embedding endpoint is derived by
10/// substituting `/complete` → `/embed` in the URL.
11#[derive(Debug, Clone)]
12pub struct OpenAiCompatibleProvider {
13    url: String,
14    api_key: Option<String>,
15    model_id: String,
16    client: reqwest::Client,
17}
18
19impl OpenAiCompatibleProvider {
20    /// Construct a provider pointing at the given completion URL.
21    ///
22    /// `model_id` is what consumers persist alongside analysis results to
23    /// detect when re-analysis is required (typical format:
24    /// `"qwen3.5-9b/v8"`).
25    pub fn new(url: String, api_key: Option<String>, model_id: String) -> Self {
26        let client = reqwest::Client::builder()
27            .connect_timeout(std::time::Duration::from_secs(10))
28            .build()
29            .unwrap_or_default();
30        Self {
31            url,
32            api_key,
33            model_id,
34            client,
35        }
36    }
37
38    /// Construct with a caller-supplied `reqwest::Client` (e.g. one with a
39    /// shared connection pool, custom TLS config, or proxy settings).
40    pub fn with_client(
41        url: String,
42        api_key: Option<String>,
43        model_id: String,
44        client: reqwest::Client,
45    ) -> Self {
46        Self {
47            url,
48            api_key,
49            model_id,
50            client,
51        }
52    }
53}
54
55#[async_trait]
56impl LlmProvider for OpenAiCompatibleProvider {
57    async fn complete(&self, system: &str, user_message: &str, temperature: f32) -> Option<String> {
58        let body = serde_json::json!({
59            "system": system,
60            "messages": [{"role": "user", "content": user_message}],
61            "temperature": temperature
62        });
63
64        // no `format` param — uses stream mode on server side (no timeout as long as tokens flow)
65        // 900s safety timeout covers entire send+read cycle
66        for attempt in 0..3u32 {
67            let result = tokio::time::timeout(std::time::Duration::from_secs(900), async {
68                let mut req = self.client.post(&self.url).json(&body);
69                if let Some(ref key) = self.api_key {
70                    req = req
71                        .header("Authorization", format!("Bearer {key}"))
72                        .header("x-caller", "mailrs");
73                }
74                let response = req.send().await?;
75
76                if response.status().as_u16() == 429 {
77                    return Ok::<Option<String>, reqwest::Error>(Some("__429__".into()));
78                }
79
80                if !response.status().is_success() {
81                    let status = response.status();
82                    let text = response.text().await.unwrap_or_default();
83                    tracing::warn!(
84                        event = "llm_http_error",
85                        status = %status,
86                        body = %&text[..text.len().min(200)]
87                    );
88                    return Ok(None);
89                }
90
91                let json: serde_json::Value = response.json().await?;
92                Ok(json["content"].as_str().map(|s| s.to_string()))
93            })
94            .await;
95
96            match result {
97                Ok(Ok(Some(ref s))) if s == "__429__" => {
98                    let wait = if attempt < 2 { 15 } else { 30 };
99                    tracing::warn!(
100                        event = "llm_rate_limited",
101                        attempt = attempt + 1,
102                        retry_in_s = wait
103                    );
104                    tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
105                    continue;
106                }
107                Ok(Ok(content)) => return content,
108                Ok(Err(e)) => {
109                    tracing::warn!(event = "llm_request_error", error = %e);
110                    return None;
111                }
112                Err(_) => {
113                    tracing::warn!(event = "llm_request_timeout", timeout_s = 900);
114                    return None;
115                }
116            }
117        }
118
119        tracing::warn!(event = "llm_rate_limited_giving_up");
120        None
121    }
122
123    async fn embed(&self, text: &str) -> Option<Vec<f32>> {
124        let embed_url = self.url.replace("/complete", "/embed");
125        let body = serde_json::json!({ "input": text });
126
127        for attempt in 0..2u32 {
128            let mut req = self.client.post(&embed_url).json(&body);
129            if let Some(ref key) = self.api_key {
130                req = req
131                    .header("Authorization", format!("Bearer {key}"))
132                    .header("x-caller", "mailrs");
133            }
134            let response =
135                match tokio::time::timeout(std::time::Duration::from_secs(10), req.send()).await {
136                    Ok(Ok(resp)) => resp,
137                    Ok(Err(e)) => {
138                        tracing::warn!(event = "embedding_request_error", error = %e);
139                        return None;
140                    }
141                    Err(_) => {
142                        tracing::warn!(event = "embedding_request_timeout", timeout_s = 10);
143                        return None;
144                    }
145                };
146
147            if response.status().as_u16() == 429 {
148                let wait = if attempt < 2 { 15 } else { 30 };
149                tracing::warn!(
150                    event = "embedding_rate_limited",
151                    attempt = attempt + 1,
152                    retry_in_s = wait
153                );
154                tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
155                continue;
156            }
157
158            if !response.status().is_success() {
159                let status = response.status();
160                let text = response.text().await.unwrap_or_default();
161                tracing::warn!(
162                    event = "embedding_http_error",
163                    status = %status,
164                    body = %&text[..text.len().min(200)]
165                );
166                return None;
167            }
168
169            let json: serde_json::Value = match response.json().await {
170                Ok(v) => v,
171                Err(e) => {
172                    tracing::warn!(event = "embedding_parse_error", error = %e);
173                    return None;
174                }
175            };
176
177            let values = json["embeddings"]
178                .as_array()
179                .and_then(|arr| arr.first())
180                .and_then(|v| v.as_array())?;
181
182            let embedding: Vec<f32> = values
183                .iter()
184                .filter_map(|v| v.as_f64().map(|f| f as f32))
185                .collect();
186
187            let dims = json["dimensions"].as_u64().unwrap_or(1024) as usize;
188            return if embedding.len() == dims {
189                Some(embedding)
190            } else {
191                tracing::warn!(
192                    event = "embedding_bad_dim",
193                    got = embedding.len(),
194                    expected = dims
195                );
196                None
197            };
198        }
199
200        tracing::warn!(event = "embedding_giving_up");
201        None
202    }
203
204    fn model_id(&self) -> &str {
205        &self.model_id
206    }
207}