Skip to main content

neuromance_client/
lib.rs

1//! # neuromance-client
2//!
3//! Client library for interacting with LLM inference providers.
4//!
5//! Provides a unified `LLMClient` trait for various LLM providers. Currently supports
6//! OpenAI-compatible APIs with tool/function calling and streaming.
7
8use 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
23/// A retry policy for SSE streams that never retries.
24///
25/// Useful for handling retries at a higher level rather than automatic reconnection.
26pub 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        // Never retry - return None to indicate no retry should happen
35        None
36    }
37
38    fn set_reconnection_time(&mut self, _duration: std::time::Duration) {
39        // Ignored - we never retry anyway
40    }
41}
42
43/// Trait for LLM client implementations.
44///
45/// Provides a unified interface for interacting with different LLM providers.
46/// Implementations must be async and thread-safe (Send + Sync).
47#[must_use = "LLMClient must be used to make requests"]
48#[async_trait]
49pub trait LLMClient: Send + Sync {
50    /// Get the client's configuration.
51    fn config(&self) -> &Config;
52
53    /// Send a chat completion request to the LLM.
54    async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse>;
55
56    /// Send a streaming chat completion request to the LLM.
57    ///
58    /// Returns a stream of incremental response chunks for real-time display.
59    async fn chat_stream(
60        &self,
61        request: &ChatRequest,
62    ) -> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk>> + Send>>>;
63
64    /// Check if the client supports tool/function calling.
65    fn supports_tools(&self) -> bool;
66
67    /// Check if the client supports streaming responses.
68    fn supports_streaming(&self) -> bool;
69
70    /// Validate a configuration object.
71    ///
72    /// Checks parameter ranges: temperature (0.0-2.0), top_p (0.0-1.0), frequency_penalty (-2.0-2.0).
73    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    /// Validate a chat request before sending.
96    ///
97    /// Checks messages exist and that tools/streaming are supported if requested.
98    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    // Mock implementation for testing
125    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            // Create a simple mock stream with one chunk
182            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}