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