1use std::pin::Pin;
9
10use anyhow::Result;
11use async_trait::async_trait;
12use futures::Stream;
13
14use neuromance_common::client::ChatChunk;
15use neuromance_common::{ChatRequest, ChatResponse, Config};
16
17mod error;
18pub mod openai;
19
20pub use error::ClientError;
21pub use openai::OpenAIClient;
22
23pub struct NoRetryPolicy;
27
28impl reqwest_eventsource::retry::RetryPolicy for NoRetryPolicy {
29 fn retry(
30 &self,
31 _error: &reqwest_eventsource::Error,
32 _last_retry: Option<(usize, std::time::Duration)>,
33 ) -> Option<std::time::Duration> {
34 None
36 }
37
38 fn set_reconnection_time(&mut self, _duration: std::time::Duration) {
39 }
41}
42
43#[must_use = "LLMClient must be used to make requests"]
48#[async_trait]
49pub trait LLMClient: Send + Sync {
50 fn config(&self) -> &Config;
52
53 async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse>;
55
56 async fn chat_stream(
60 &self,
61 request: &ChatRequest,
62 ) -> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk>> + Send>>>;
63
64 fn supports_tools(&self) -> bool;
66
67 fn supports_streaming(&self) -> bool;
69
70 fn validate_config(&self, config: Config) -> Result<()> {
74 if config
75 .temperature
76 .is_some_and(|t| !(0.0..=2.0).contains(&t))
77 {
78 return Err(ClientError::InvalidTemperature.into());
79 }
80
81 if config.top_p.is_some_and(|p| !(0.0..=1.0).contains(&p)) {
82 return Err(ClientError::InvalidTopP.into());
83 }
84
85 if config
86 .frequency_penalty
87 .is_some_and(|f| !(-2.0..=2.0).contains(&f))
88 {
89 return Err(ClientError::InvalidFrequencyPenalty.into());
90 }
91
92 Ok(())
93 }
94
95 fn validate_request(&self, request: &ChatRequest) -> Result<()> {
99 request
100 .validate_has_messages()
101 .map_err(|e| ClientError::InvalidRequest(e.to_string()))?;
102
103 if !self.supports_tools() && request.has_tools() {
104 return Err(ClientError::ToolsNotSupported.into());
105 }
106
107 if !self.supports_streaming() && request.is_streaming() {
108 return Err(ClientError::StreamingNotSupported.into());
109 }
110
111 Ok(())
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use neuromance_common::chat::Message;
119 use neuromance_common::client::{ChatResponse, ToolChoice};
120 use neuromance_common::tools::Tool;
121 use std::collections::HashMap;
122 use uuid::Uuid;
123
124 struct MockLLMClient {
126 config: Config,
127 supports_tools: bool,
128 supports_streaming: bool,
129 }
130
131 impl MockLLMClient {
132 fn new() -> Self {
133 Self {
134 config: Config::new("mock", "mock-model"),
135 supports_tools: true,
136 supports_streaming: true,
137 }
138 }
139
140 fn without_tools() -> Self {
141 Self {
142 config: Config::new("mock", "mock-model"),
143 supports_tools: false,
144 supports_streaming: true,
145 }
146 }
147
148 fn without_streaming() -> Self {
149 Self {
150 config: Config::new("mock", "mock-model"),
151 supports_tools: true,
152 supports_streaming: false,
153 }
154 }
155 }
156
157 #[async_trait]
158 impl LLMClient for MockLLMClient {
159 fn config(&self) -> &Config {
160 &self.config
161 }
162
163 async fn chat(&self, _request: &ChatRequest) -> Result<ChatResponse> {
164 Ok(ChatResponse {
165 message: create_test_message(),
166 model: "mock-model".to_string(),
167 usage: None,
168 finish_reason: None,
169 created_at: chrono::Utc::now(),
170 response_id: Some("test-response".to_string()),
171 metadata: HashMap::new(),
172 })
173 }
174
175 async fn chat_stream(
176 &self,
177 _request: &ChatRequest,
178 ) -> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk>> + Send>>> {
179 use futures::stream;
180
181 let chunk = ChatChunk {
183 model: "mock-model".to_string(),
184 delta_content: Some("Hello".to_string()),
185 delta_role: Some(neuromance_common::chat::MessageRole::Assistant),
186 delta_tool_calls: None,
187 finish_reason: None,
188 usage: None,
189 response_id: Some("test-response".to_string()),
190 created_at: chrono::Utc::now(),
191 metadata: HashMap::new(),
192 };
193
194 Ok(Box::pin(stream::iter(vec![Ok(chunk)])))
195 }
196
197 fn supports_tools(&self) -> bool {
198 self.supports_tools
199 }
200
201 fn supports_streaming(&self) -> bool {
202 self.supports_streaming
203 }
204 }
205
206 fn create_test_message() -> Message {
207 Message::user(Uuid::new_v4(), "Test message")
208 }
209
210 fn create_test_tool() -> Tool {
211 use neuromance_common::tools::{Function, Property};
212 use std::collections::HashMap;
213
214 let mut properties = HashMap::new();
215 properties.insert(
216 "arg".to_string(),
217 Property {
218 prop_type: "string".to_string(),
219 description: "A test argument".to_string(),
220 },
221 );
222
223 Tool {
224 r#type: "function".to_string(),
225 function: Function {
226 name: "test_function".to_string(),
227 description: "A test function".to_string(),
228 parameters: serde_json::json!({
229 "type": "object",
230 "properties": properties,
231 "required": ["arg"]
232 }),
233 },
234 }
235 }
236
237 #[test]
238 fn test_validate_request_empty_messages() {
239 let client = MockLLMClient::new();
240 let request = ChatRequest {
241 model: Some("test-model".to_string()),
242 messages: vec![].into(),
243 tools: None,
244 tool_choice: None,
245 temperature: None,
246 max_tokens: None,
247 top_p: None,
248 frequency_penalty: None,
249 presence_penalty: None,
250 stop: None,
251 stream: false,
252 user: None,
253 enable_thinking: None,
254 metadata: HashMap::new(),
255 };
256
257 let result = client.validate_request(&request);
258 assert!(result.is_err());
259 let error = result.unwrap_err();
260 let client_error = error.downcast_ref::<ClientError>().unwrap();
261 assert!(matches!(client_error, ClientError::InvalidRequest(_)));
262 }
263
264 #[test]
265 fn test_validate_request_valid() {
266 let client = MockLLMClient::new();
267 let request = ChatRequest {
268 model: Some("test-model".to_string()),
269 messages: vec![create_test_message()].into(),
270 tools: None,
271 tool_choice: None,
272 temperature: None,
273 max_tokens: None,
274 top_p: None,
275 frequency_penalty: None,
276 presence_penalty: None,
277 stop: None,
278 stream: false,
279 user: None,
280 enable_thinking: None,
281 metadata: HashMap::new(),
282 };
283
284 let result = client.validate_request(&request);
285 assert!(result.is_ok());
286 }
287
288 #[test]
289 fn test_validate_request_tools_not_supported() {
290 let client = MockLLMClient::without_tools();
291 let request = ChatRequest {
292 model: Some("test-model".to_string()),
293 messages: vec![create_test_message()].into(),
294 tools: Some(vec![create_test_tool()]),
295 tool_choice: None,
296 temperature: None,
297 max_tokens: None,
298 top_p: None,
299 frequency_penalty: None,
300 presence_penalty: None,
301 stop: None,
302 stream: false,
303 user: None,
304 enable_thinking: None,
305 metadata: HashMap::new(),
306 };
307
308 let result = client.validate_request(&request);
309 assert!(result.is_err());
310 let error = result.unwrap_err();
311 let client_error = error.downcast_ref::<ClientError>().unwrap();
312 assert!(matches!(client_error, ClientError::ToolsNotSupported));
313 }
314
315 #[test]
316 fn test_validate_request_tools_supported() {
317 let client = MockLLMClient::new();
318 let request = ChatRequest {
319 model: Some("test-model".to_string()),
320 messages: vec![create_test_message()].into(),
321 tools: Some(vec![create_test_tool()]),
322 tool_choice: Some(ToolChoice::Auto),
323 temperature: None,
324 max_tokens: None,
325 top_p: None,
326 frequency_penalty: None,
327 presence_penalty: None,
328 stop: None,
329 stream: false,
330 user: None,
331 enable_thinking: None,
332 metadata: HashMap::new(),
333 };
334
335 let result = client.validate_request(&request);
336 assert!(result.is_ok());
337 }
338
339 #[test]
340 fn test_validate_request_streaming_not_supported() {
341 let client = MockLLMClient::without_streaming();
342 let request = ChatRequest {
343 model: Some("test-model".to_string()),
344 messages: vec![create_test_message()].into(),
345 tools: None,
346 tool_choice: None,
347 temperature: None,
348 max_tokens: None,
349 top_p: None,
350 frequency_penalty: None,
351 presence_penalty: None,
352 stop: None,
353 stream: true,
354 user: None,
355 enable_thinking: None,
356 metadata: HashMap::new(),
357 };
358
359 let result = client.validate_request(&request);
360 assert!(result.is_err());
361 let error = result.unwrap_err();
362 let client_error = error.downcast_ref::<ClientError>().unwrap();
363 assert!(matches!(client_error, ClientError::StreamingNotSupported));
364 }
365
366 #[test]
367 fn test_validate_request_streaming_supported() {
368 let client = MockLLMClient::new();
369 let request = ChatRequest {
370 model: Some("test-model".to_string()),
371 messages: vec![create_test_message()].into(),
372 tools: None,
373 tool_choice: None,
374 temperature: None,
375 max_tokens: None,
376 top_p: None,
377 frequency_penalty: None,
378 presence_penalty: None,
379 stop: None,
380 stream: true,
381 user: None,
382 enable_thinking: None,
383 metadata: HashMap::new(),
384 };
385
386 let result = client.validate_request(&request);
387 assert!(result.is_ok());
388 }
389
390 #[test]
391 fn test_client_config_access() {
392 let client = MockLLMClient::new();
393 let config = client.config();
394 assert_eq!(config.provider, "mock");
395 assert_eq!(config.model, "mock-model");
396 }
397
398 #[test]
399 fn test_supports_tools() {
400 let client_with_tools = MockLLMClient::new();
401 let client_without_tools = MockLLMClient::without_tools();
402
403 assert!(client_with_tools.supports_tools());
404 assert!(!client_without_tools.supports_tools());
405 }
406
407 #[test]
408 fn test_supports_streaming() {
409 let client_with_streaming = MockLLMClient::new();
410 let client_without_streaming = MockLLMClient::without_streaming();
411
412 assert!(client_with_streaming.supports_streaming());
413 assert!(!client_without_streaming.supports_streaming());
414 }
415
416 #[tokio::test]
417 async fn test_chat_method() {
418 let client = MockLLMClient::new();
419 let request = ChatRequest {
420 model: Some("test-model".to_string()),
421 messages: vec![create_test_message()].into(),
422 tools: None,
423 tool_choice: None,
424 temperature: None,
425 max_tokens: None,
426 top_p: None,
427 frequency_penalty: None,
428 presence_penalty: None,
429 stop: None,
430 stream: false,
431 user: None,
432 enable_thinking: None,
433 metadata: HashMap::new(),
434 };
435
436 let result = client.chat(&request).await;
437 assert!(result.is_ok());
438
439 let response = result.unwrap();
440 assert_eq!(response.response_id, Some("test-response".to_string()));
441 assert_eq!(response.model, "mock-model");
442 }
443}