1use crate::error::LlmError;
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::sync::Arc;
16use tokio::sync::mpsc;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Message {
23 pub role: String,
25 pub content: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ChatResponse {
32 pub content: String,
34 pub model: String,
36 pub usage: Option<TokenUsage>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TokenUsage {
43 pub prompt_tokens: u32,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub cached_input_tokens: Option<u32>,
48 pub completion_tokens: u32,
50 pub total_tokens: u32,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub tool_call_count: Option<u32>,
55 #[serde(default)]
57 pub prompt_eval_nanos: u64,
58 #[serde(default)]
60 pub eval_nanos: u64,
61 #[serde(default)]
63 pub total_nanos: u64,
64 #[serde(default)]
66 pub load_nanos: u64,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ModelInfo {
72 pub name: String,
74 #[serde(default)]
76 pub size: u64,
77 #[serde(default)]
79 pub modified_at: String,
80 #[serde(default)]
82 pub digest: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct ToolDefinition {
94 pub name: String,
96 pub description: String,
98 pub parameters: serde_json::Value,
100}
101
102#[derive(Debug, Clone)]
109pub enum StreamEvent {
110 Token(String),
112 Thinking(String),
115 ToolCall {
117 id: String,
119 name: String,
121 arguments_json: String,
123 },
124}
125
126#[async_trait]
132pub trait LlmClient: Send + Sync {
133 async fn chat(
141 &self,
142 messages: &[Message],
143 model: Option<&str>,
144 tools: Option<Vec<ToolDefinition>>,
145 ) -> Result<ChatResponse, LlmError>;
146
147 async fn chat_stream(
160 &self,
161 messages: &[Message],
162 model: Option<&str>,
163 event_tx: Option<mpsc::UnboundedSender<StreamEvent>>,
164 tools: Option<Vec<ToolDefinition>>,
165 ) -> Result<ChatResponse, LlmError> {
166 let response = self.chat(messages, model, tools).await?;
167 if let Some(tx) = event_tx {
168 let _ = tx.send(StreamEvent::Token(response.content.clone()));
169 }
170 Ok(response)
171 }
172
173 async fn chat_stream_with_params(
183 &self,
184 messages: &[Message],
185 model: Option<&str>,
186 event_tx: Option<mpsc::UnboundedSender<StreamEvent>>,
187 tools: Option<Vec<ToolDefinition>>,
188 _max_completion_tokens: Option<u32>,
189 _temperature: Option<f32>,
190 ) -> Result<ChatResponse, LlmError> {
191 self.chat_stream(messages, model, event_tx, tools).await
192 }
193
194 async fn chat_json(
196 &self,
197 messages: &[Message],
198 model: Option<&str>,
199 tools: Option<Vec<ToolDefinition>>,
200 ) -> Result<serde_json::Value, LlmError>;
201
202 async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError>;
208
209 async fn health_check(&self) -> Result<bool, LlmError>;
215}
216
217pub trait LlmClientFactory: Send + Sync {
224 fn name(&self) -> &str;
226
227 fn display_name(&self) -> &str;
229
230 fn create(&self, config: &HashMap<String, String>) -> Result<Arc<dyn LlmClient>, LlmError>;
236
237 fn validate_config(&self, config: &HashMap<String, String>) -> Vec<String>;
239
240 fn required_config_keys(&self) -> Vec<&str>;
242}
243
244pub struct LlmRegistry {
248 providers: HashMap<String, Arc<dyn LlmClientFactory>>,
249}
250
251impl LlmRegistry {
252 #[must_use]
254 pub fn new() -> Self {
255 Self { providers: HashMap::new() }
256 }
257
258 pub fn register(&mut self, name: String, factory: Arc<dyn LlmClientFactory>) {
260 self.providers.insert(name, factory);
261 }
262
263 #[must_use]
265 pub fn get(&self, name: &str) -> Option<&Arc<dyn LlmClientFactory>> {
266 self.providers.get(name)
267 }
268
269 #[must_use]
271 pub fn list(&self) -> Vec<(&str, &str)> {
272 self.providers.values().map(|f| (f.name(), f.display_name())).collect()
273 }
274
275 pub fn create_client(
281 &self,
282 provider_name: &str,
283 config: &HashMap<String, String>,
284 ) -> Result<Arc<dyn LlmClient>, LlmError> {
285 let factory = self
286 .providers
287 .get(provider_name)
288 .ok_or_else(|| LlmError::UnsupportedProvider(provider_name.to_string()))?;
289 factory.create(config)
290 }
291}
292
293impl Default for LlmRegistry {
294 fn default() -> Self {
295 Self::new()
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 struct MockLlmClient;
304
305 #[async_trait]
306 impl LlmClient for MockLlmClient {
307 async fn chat(
308 &self,
309 _messages: &[Message],
310 _model: Option<&str>,
311 _tools: Option<Vec<ToolDefinition>>,
312 ) -> Result<ChatResponse, LlmError> {
313 Ok(ChatResponse {
314 content: "test response".to_string(),
315 model: "test-model".to_string(),
316 usage: None,
317 })
318 }
319
320 async fn chat_json(
321 &self,
322 _messages: &[Message],
323 _model: Option<&str>,
324 _tools: Option<Vec<ToolDefinition>>,
325 ) -> Result<serde_json::Value, LlmError> {
326 Ok(serde_json::json!({"response": "test"}))
327 }
328
329 async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
330 Ok(vec![ModelInfo {
331 name: "test-model".to_string(),
332 size: 0,
333 modified_at: String::new(),
334 digest: String::new(),
335 }])
336 }
337
338 async fn health_check(&self) -> Result<bool, LlmError> {
339 Ok(true)
340 }
341 }
342
343 struct MockLlmFactory;
344
345 impl LlmClientFactory for MockLlmFactory {
346 fn name(&self) -> &'static str {
347 "mock"
348 }
349
350 fn display_name(&self) -> &'static str {
351 "Mock Provider"
352 }
353
354 fn create(
355 &self,
356 _config: &HashMap<String, String>,
357 ) -> Result<Arc<dyn LlmClient>, LlmError> {
358 Ok(Arc::new(MockLlmClient))
359 }
360
361 fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
362 Vec::new()
363 }
364
365 fn required_config_keys(&self) -> Vec<&str> {
366 vec!["base_url"]
367 }
368 }
369
370 #[test]
371 fn llm_registry_resolves_registered_provider() {
372 let mut registry = LlmRegistry::new();
373 registry.register("mock".to_string(), Arc::new(MockLlmFactory));
374 assert!(registry.get("mock").is_some());
375 }
376
377 #[test]
378 fn llm_registry_create_client_delegates_to_factory() {
379 let mut registry = LlmRegistry::new();
380 registry.register("mock".to_string(), Arc::new(MockLlmFactory));
381 let config = HashMap::from([("base_url".to_string(), "http://localhost".to_string())]);
382 let client = registry.create_client("mock", &config);
383 assert!(client.is_ok());
384 }
385
386 #[test]
387 fn llm_registry_unknown_provider_returns_error() {
388 let registry = LlmRegistry::new();
389 let config = HashMap::new();
390 let result = registry.create_client("nonexistent", &config);
391 assert!(result.is_err());
392 let err = result.err().unwrap();
393 assert!(matches!(err, LlmError::UnsupportedProvider(_)));
394 }
395
396 #[test]
397 fn llm_registry_list_shows_all_providers() {
398 let mut registry = LlmRegistry::new();
399 registry.register("mock1".to_string(), Arc::new(MockLlmFactory));
400 registry.register("mock2".to_string(), Arc::new(MockLlmFactory));
401 assert_eq!(registry.list().len(), 2);
402 }
403
404 #[tokio::test]
405 async fn chat_stream_default_sends_full_content_as_single_token() {
406 let client = MockLlmClient;
407 let (tx, mut rx) = mpsc::unbounded_channel();
408 let messages = vec![Message { role: "user".to_string(), content: "hello".to_string() }];
409
410 let response = client.chat_stream(&messages, None, Some(tx), None).await.unwrap();
411
412 assert_eq!(response.content, "test response");
413 let event = rx.recv().await.unwrap();
415 assert!(matches!(event, StreamEvent::Token(ref s) if s == "test response"));
416 assert!(rx.recv().await.is_none());
418 }
419
420 #[tokio::test]
421 async fn chat_stream_with_none_tx_returns_response_without_events() {
422 let client = MockLlmClient;
423 let messages = vec![Message { role: "user".to_string(), content: "hello".to_string() }];
424
425 let response = client.chat_stream(&messages, None, None, None).await.unwrap();
426
427 assert_eq!(response.content, "test response");
428 }
429
430 #[test]
431 fn stream_event_token_carries_content() {
432 let event = StreamEvent::Token("hello world".to_string());
433 match event {
434 StreamEvent::Token(s) => assert_eq!(s, "hello world"),
435 StreamEvent::Thinking(_) | StreamEvent::ToolCall { .. } => {
436 panic!("expected Token variant")
437 }
438 }
439 }
440
441 #[test]
442 fn stream_event_thinking_carries_reasoning() {
443 let event = StreamEvent::Thinking("let me analyze...".to_string());
444 match event {
445 StreamEvent::Thinking(s) => assert_eq!(s, "let me analyze..."),
446 StreamEvent::Token(_) | StreamEvent::ToolCall { .. } => {
447 panic!("expected Thinking variant")
448 }
449 }
450 }
451}