daimon_provider_local/
generic.rs1use std::time::Duration;
23
24use daimon_core::{
25 ChatRequest, ChatResponse, DaimonError, EmbeddingModel, Model, ResponseStream, Result,
26};
27
28use crate::openai_compat::{
29 EmbedRequest, Http, api_error, build_chat_request, parse_chat_response, parse_embed_response,
30 stream_chat_response,
31};
32
33#[derive(Debug)]
36pub struct OpenAiCompatible {
37 http: Http,
38 model: Option<String>,
39 extra: serde_json::Map<String, serde_json::Value>,
40}
41
42impl OpenAiCompatible {
43 pub fn new(base_url: impl Into<String>) -> Self {
45 let base_url = base_url.into();
46 Self {
47 http: Http::new(&base_url),
48 model: None,
49 extra: serde_json::Map::new(),
50 }
51 }
52
53 pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
55 self.http.set_base_url(url);
56 self
57 }
58
59 pub fn with_model(mut self, name: impl Into<String>) -> Self {
61 self.model = Some(name.into());
62 self
63 }
64
65 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
67 self.http.set_api_key(key);
68 self
69 }
70
71 pub fn with_timeout(mut self, timeout: Duration) -> Self {
73 self.http.set_timeout(timeout);
74 self
75 }
76
77 pub fn with_max_retries(mut self, retries: u32) -> Self {
80 self.http.set_max_retries(retries);
81 self
82 }
83
84 pub fn allow_plaintext_api_key(mut self) -> Self {
88 self.http.set_allow_plaintext_api_key(true);
89 self
90 }
91
92 pub fn with_extra_field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
96 self.extra.insert(key.into(), value);
97 self
98 }
99}
100
101impl Model for OpenAiCompatible {
102 #[tracing::instrument(skip_all, fields(model = self.model.as_deref().unwrap_or("openai-compatible")))]
103 async fn generate(&self, request: &ChatRequest) -> Result<ChatResponse> {
104 let body = build_chat_request(
105 &request.messages,
106 &request.tools,
107 self.model.as_deref(),
108 request.temperature,
109 request.max_tokens,
110 false,
111 self.extra.clone(),
112 );
113
114 let response = self.http.post("/v1/chat/completions", &body).await?;
115 let status = response.status();
116 if !status.is_success() {
117 let text = response.text().await.unwrap_or_default();
118 return Err(api_error(status, &text, "OpenAI-compatible"));
119 }
120
121 let bytes = response.bytes().await.map_err(|e| {
122 DaimonError::Model(format!("OpenAI-compatible response read error: {e}"))
123 })?;
124 parse_chat_response(&bytes, "OpenAI-compatible")
125 }
126
127 #[tracing::instrument(skip_all, fields(model = self.model.as_deref().unwrap_or("openai-compatible")))]
128 async fn generate_stream(&self, request: &ChatRequest) -> Result<ResponseStream> {
129 let body = build_chat_request(
130 &request.messages,
131 &request.tools,
132 self.model.as_deref(),
133 request.temperature,
134 request.max_tokens,
135 true,
136 self.extra.clone(),
137 );
138
139 let response = self
140 .http
141 .post_streaming("/v1/chat/completions", &body)
142 .await?;
143 let status = response.status();
144 if !status.is_success() {
145 let text = response.text().await.unwrap_or_default();
146 return Err(api_error(status, &text, "OpenAI-compatible"));
147 }
148
149 Ok(stream_chat_response(response, "OpenAI-compatible"))
150 }
151}
152
153#[derive(Debug)]
156pub struct OpenAiCompatibleEmbedding {
157 http: Http,
158 model: Option<String>,
159 dimensions: usize,
160}
161
162impl OpenAiCompatibleEmbedding {
163 pub fn new(base_url: impl Into<String>) -> Self {
165 let base_url = base_url.into();
166 Self {
167 http: Http::new(&base_url),
168 model: None,
169 dimensions: 768,
170 }
171 }
172
173 pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
175 self.http.set_base_url(url);
176 self
177 }
178
179 pub fn with_model(mut self, name: impl Into<String>) -> Self {
181 self.model = Some(name.into());
182 self
183 }
184
185 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
187 self.http.set_api_key(key);
188 self
189 }
190
191 pub fn with_timeout(mut self, timeout: Duration) -> Self {
193 self.http.set_timeout(timeout);
194 self
195 }
196
197 pub fn with_max_retries(mut self, retries: u32) -> Self {
200 self.http.set_max_retries(retries);
201 self
202 }
203
204 pub fn allow_plaintext_api_key(mut self) -> Self {
208 self.http.set_allow_plaintext_api_key(true);
209 self
210 }
211
212 pub fn with_dimensions(mut self, dims: usize) -> Self {
214 self.dimensions = dims;
215 self
216 }
217}
218
219impl EmbeddingModel for OpenAiCompatibleEmbedding {
220 async fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
221 let body = EmbedRequest {
222 model: self.model.as_deref(),
223 input: texts,
224 };
225 let resp = self.http.post("/v1/embeddings", &body).await?;
226 let status = resp.status();
227 if !status.is_success() {
228 let text = resp.text().await.unwrap_or_default();
229 return Err(api_error(status, &text, "OpenAI-compatible"));
230 }
231 let bytes = resp.bytes().await.map_err(|e| {
232 DaimonError::Model(format!("OpenAI-compatible embedding read error: {e}"))
233 })?;
234 parse_embed_response(&bytes, "OpenAI-compatible")
235 }
236
237 fn dimensions(&self) -> usize {
238 self.dimensions
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_builder_defaults() {
248 let model = OpenAiCompatible::new("http://localhost:8000");
249 assert_eq!(model.http.base_url(), "http://localhost:8000");
250 assert!(model.model.is_none());
251 assert!(model.extra.is_empty());
252 }
253
254 #[test]
255 fn test_extra_field() {
256 let model = OpenAiCompatible::new("http://localhost:8000")
257 .with_extra_field("repetition_penalty", serde_json::json!(1.1));
258 assert_eq!(model.extra["repetition_penalty"], 1.1);
259 }
260
261 #[test]
262 fn test_builder_chain() {
263 let model = OpenAiCompatible::new("http://localhost:8000")
264 .with_model("my-model")
265 .with_api_key("secret")
266 .with_timeout(Duration::from_secs(15))
267 .with_max_retries(5);
268 assert_eq!(model.model.as_deref(), Some("my-model"));
269 assert_eq!(model.http.api_key(), Some("secret"));
270 assert_eq!(model.http.max_retries(), 5);
271 }
272
273 #[test]
274 fn test_embedding_builder_defaults() {
275 let embed = OpenAiCompatibleEmbedding::new("http://localhost:8000");
276 assert_eq!(embed.dimensions, 768);
277 }
278
279 #[test]
280 fn test_debug_redacts_api_key() {
281 let model =
282 OpenAiCompatible::new("http://localhost:8000").with_api_key("sk-supersecret-key-value");
283 let dbg = format!("{model:?}");
284 assert!(
285 !dbg.contains("sk-supersecret-key-value"),
286 "Debug output must not contain the plaintext API key: {dbg}"
287 );
288 assert!(dbg.contains("[redacted]"));
289 }
290
291 #[test]
292 fn test_embedding_debug_redacts_api_key() {
293 let embed = OpenAiCompatibleEmbedding::new("http://localhost:8000")
294 .with_api_key("sk-supersecret-embed-key");
295 let dbg = format!("{embed:?}");
296 assert!(
297 !dbg.contains("sk-supersecret-embed-key"),
298 "Debug output must not contain the plaintext API key: {dbg}"
299 );
300 assert!(dbg.contains("[redacted]"));
301 }
302
303 #[tokio::test]
304 async fn test_plaintext_api_key_over_http_is_blocked_by_default() {
305 let model = OpenAiCompatible::new("http://localhost:8000").with_api_key("secret");
306 let request = ChatRequest::new(vec![daimon_core::Message::user("hi")]);
307 let err = model.generate(&request).await.unwrap_err();
308 assert!(matches!(err, DaimonError::Builder(_)));
309 }
310
311 #[tokio::test]
312 async fn test_plaintext_api_key_allowed_when_opted_in_does_not_hard_error() {
313 let model = OpenAiCompatible::new("http://localhost:1")
317 .with_api_key("secret")
318 .with_max_retries(0)
319 .allow_plaintext_api_key();
320 let request = ChatRequest::new(vec![daimon_core::Message::user("hi")]);
321 let err = model.generate(&request).await.unwrap_err();
322 assert!(!matches!(err, DaimonError::Builder(_)));
323 }
324}