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, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum PromptReuse {
38 #[default]
41 Conversation,
42 SystemPrompt,
45 Nothing,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ChatResponse {
52 pub content: String,
54 pub model: String,
56 pub usage: Option<TokenUsage>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TokenUsage {
63 pub prompt_tokens: u32,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub cached_input_tokens: Option<u32>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub cache_write_tokens: Option<u32>,
74 pub completion_tokens: u32,
76 pub total_tokens: u32,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub tool_call_count: Option<u32>,
81 #[serde(default)]
83 pub prompt_eval_nanos: u64,
84 #[serde(default)]
86 pub eval_nanos: u64,
87 #[serde(default)]
89 pub total_nanos: u64,
90 #[serde(default)]
92 pub load_nanos: u64,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub advisor: Option<AdvisorTokenUsage>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109pub struct AdvisorTokenUsage {
110 pub model: String,
113 pub calls: u32,
115 pub input_tokens: u32,
117 pub cache_read_tokens: u32,
119 pub cache_write_tokens: u32,
121 pub output_tokens: u32,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ModelInfo {
128 pub name: String,
130 #[serde(default)]
132 pub size: u64,
133 #[serde(default)]
135 pub modified_at: String,
136 #[serde(default)]
138 pub digest: String,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ToolDefinition {
150 pub name: String,
152 pub description: String,
154 pub parameters: serde_json::Value,
156}
157
158#[derive(Debug, Clone)]
165pub enum StreamEvent {
166 Token(String),
168 Thinking(String),
171 ToolCall {
173 id: String,
175 name: String,
177 arguments_json: String,
179 },
180}
181
182#[async_trait]
188pub trait LlmClient: Send + Sync {
189 async fn chat(
197 &self,
198 messages: &[Message],
199 model: Option<&str>,
200 tools: Option<Vec<ToolDefinition>>,
201 ) -> Result<ChatResponse, LlmError>;
202
203 async fn chat_stream(
216 &self,
217 messages: &[Message],
218 model: Option<&str>,
219 event_tx: Option<mpsc::UnboundedSender<StreamEvent>>,
220 tools: Option<Vec<ToolDefinition>>,
221 ) -> Result<ChatResponse, LlmError> {
222 let response = self.chat(messages, model, tools).await?;
223 if let Some(tx) = event_tx {
224 let _ = tx.send(StreamEvent::Token(response.content.clone()));
225 }
226 Ok(response)
227 }
228
229 async fn chat_stream_with_params(
239 &self,
240 messages: &[Message],
241 model: Option<&str>,
242 event_tx: Option<mpsc::UnboundedSender<StreamEvent>>,
243 tools: Option<Vec<ToolDefinition>>,
244 _max_completion_tokens: Option<u32>,
245 _temperature: Option<f32>,
246 ) -> Result<ChatResponse, LlmError> {
247 self.chat_stream(messages, model, event_tx, tools).await
248 }
249
250 async fn chat_stream_with_reuse(
257 &self,
258 messages: &[Message],
259 model: Option<&str>,
260 event_tx: Option<mpsc::UnboundedSender<StreamEvent>>,
261 tools: Option<Vec<ToolDefinition>>,
262 _reuse: PromptReuse,
263 ) -> Result<ChatResponse, LlmError> {
264 self.chat_stream(messages, model, event_tx, tools).await
265 }
266
267 async fn chat_json(
269 &self,
270 messages: &[Message],
271 model: Option<&str>,
272 tools: Option<Vec<ToolDefinition>>,
273 ) -> Result<serde_json::Value, LlmError>;
274
275 async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError>;
281
282 async fn health_check(&self) -> Result<bool, LlmError>;
288}
289
290pub trait LlmClientFactory: Send + Sync {
297 fn name(&self) -> &str;
299
300 fn display_name(&self) -> &str;
302
303 fn create(&self, config: &HashMap<String, String>) -> Result<Arc<dyn LlmClient>, LlmError>;
309
310 fn validate_config(&self, config: &HashMap<String, String>) -> Vec<String>;
312
313 fn required_config_keys(&self) -> Vec<&str>;
315}
316
317pub struct LlmRegistry {
321 providers: HashMap<String, Arc<dyn LlmClientFactory>>,
322}
323
324impl LlmRegistry {
325 #[must_use]
327 pub fn new() -> Self {
328 Self { providers: HashMap::new() }
329 }
330
331 pub fn register(&mut self, name: String, factory: Arc<dyn LlmClientFactory>) {
333 self.providers.insert(name, factory);
334 }
335
336 #[must_use]
338 pub fn get(&self, name: &str) -> Option<&Arc<dyn LlmClientFactory>> {
339 self.providers.get(name)
340 }
341
342 #[must_use]
344 pub fn list(&self) -> Vec<(&str, &str)> {
345 self.providers.values().map(|f| (f.name(), f.display_name())).collect()
346 }
347
348 pub fn create_client(
354 &self,
355 provider_name: &str,
356 config: &HashMap<String, String>,
357 ) -> Result<Arc<dyn LlmClient>, LlmError> {
358 let factory = self
359 .providers
360 .get(provider_name)
361 .ok_or_else(|| LlmError::UnsupportedProvider(provider_name.to_string()))?;
362 factory.create(config)
363 }
364}
365
366impl Default for LlmRegistry {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn token_usage_round_trips_cache_writes_and_the_advisor() {
378 let usage = TokenUsage {
379 prompt_tokens: 4_020,
380 cached_input_tokens: Some(3_000),
381 cache_write_tokens: Some(900),
382 completion_tokens: 45,
383 total_tokens: 4_065,
384 tool_call_count: None,
385 prompt_eval_nanos: 0,
386 eval_nanos: 0,
387 total_nanos: 7,
388 load_nanos: 0,
389 advisor: Some(AdvisorTokenUsage {
390 model: "claude-opus-5-5".to_string(),
391 calls: 2,
392 input_tokens: 900,
393 cache_read_tokens: 300,
394 cache_write_tokens: 50,
395 output_tokens: 700,
396 }),
397 };
398 let json = serde_json::to_string(&usage).expect("serializes");
399 let back: TokenUsage = serde_json::from_str(&json).expect("reads back");
400 assert_eq!(back.cache_write_tokens, Some(900));
401 assert_eq!(back.advisor, usage.advisor);
402 }
403
404 #[test]
405 fn a_provider_that_reports_neither_leaves_them_out_of_the_json() {
406 let usage: TokenUsage =
407 serde_json::from_str(r#"{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}"#)
408 .expect("the older shape still reads");
409 assert_eq!(usage.cache_write_tokens, None);
410 assert!(usage.advisor.is_none());
411 let json = serde_json::to_value(&usage).expect("serializes");
412 assert!(json.get("cache_write_tokens").is_none(), "{json}");
413 assert!(json.get("advisor").is_none(), "{json}");
414 }
415
416 struct MockLlmClient;
417
418 #[async_trait]
419 impl LlmClient for MockLlmClient {
420 async fn chat(
421 &self,
422 _messages: &[Message],
423 _model: Option<&str>,
424 _tools: Option<Vec<ToolDefinition>>,
425 ) -> Result<ChatResponse, LlmError> {
426 Ok(ChatResponse {
427 content: "test response".to_string(),
428 model: "test-model".to_string(),
429 usage: None,
430 })
431 }
432
433 async fn chat_json(
434 &self,
435 _messages: &[Message],
436 _model: Option<&str>,
437 _tools: Option<Vec<ToolDefinition>>,
438 ) -> Result<serde_json::Value, LlmError> {
439 Ok(serde_json::json!({"response": "test"}))
440 }
441
442 async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
443 Ok(vec![ModelInfo {
444 name: "test-model".to_string(),
445 size: 0,
446 modified_at: String::new(),
447 digest: String::new(),
448 }])
449 }
450
451 async fn health_check(&self) -> Result<bool, LlmError> {
452 Ok(true)
453 }
454 }
455
456 struct MockLlmFactory;
457
458 impl LlmClientFactory for MockLlmFactory {
459 fn name(&self) -> &'static str {
460 "mock"
461 }
462
463 fn display_name(&self) -> &'static str {
464 "Mock Provider"
465 }
466
467 fn create(
468 &self,
469 _config: &HashMap<String, String>,
470 ) -> Result<Arc<dyn LlmClient>, LlmError> {
471 Ok(Arc::new(MockLlmClient))
472 }
473
474 fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
475 Vec::new()
476 }
477
478 fn required_config_keys(&self) -> Vec<&str> {
479 vec!["base_url"]
480 }
481 }
482
483 #[test]
484 fn llm_registry_resolves_registered_provider() {
485 let mut registry = LlmRegistry::new();
486 registry.register("mock".to_string(), Arc::new(MockLlmFactory));
487 assert!(registry.get("mock").is_some());
488 }
489
490 #[test]
491 fn llm_registry_create_client_delegates_to_factory() {
492 let mut registry = LlmRegistry::new();
493 registry.register("mock".to_string(), Arc::new(MockLlmFactory));
494 let config = HashMap::from([("base_url".to_string(), "http://localhost".to_string())]);
495 let client = registry.create_client("mock", &config);
496 assert!(client.is_ok());
497 }
498
499 #[test]
500 fn llm_registry_unknown_provider_returns_error() {
501 let registry = LlmRegistry::new();
502 let config = HashMap::new();
503 let result = registry.create_client("nonexistent", &config);
504 assert!(result.is_err());
505 let err = result.err().unwrap();
506 assert!(matches!(err, LlmError::UnsupportedProvider(_)));
507 }
508
509 #[test]
510 fn llm_registry_list_shows_all_providers() {
511 let mut registry = LlmRegistry::new();
512 registry.register("mock1".to_string(), Arc::new(MockLlmFactory));
513 registry.register("mock2".to_string(), Arc::new(MockLlmFactory));
514 assert_eq!(registry.list().len(), 2);
515 }
516
517 #[tokio::test]
518 async fn chat_stream_default_sends_full_content_as_single_token() {
519 let client = MockLlmClient;
520 let (tx, mut rx) = mpsc::unbounded_channel();
521 let messages = vec![Message { role: "user".to_string(), content: "hello".to_string() }];
522
523 let response = client.chat_stream(&messages, None, Some(tx), None).await.unwrap();
524
525 assert_eq!(response.content, "test response");
526 let event = rx.recv().await.unwrap();
528 assert!(matches!(event, StreamEvent::Token(ref s) if s == "test response"));
529 assert!(rx.recv().await.is_none());
531 }
532
533 #[tokio::test]
534 async fn chat_stream_with_none_tx_returns_response_without_events() {
535 let client = MockLlmClient;
536 let messages = vec![Message { role: "user".to_string(), content: "hello".to_string() }];
537
538 let response = client.chat_stream(&messages, None, None, None).await.unwrap();
539
540 assert_eq!(response.content, "test response");
541 }
542
543 #[test]
544 fn stream_event_token_carries_content() {
545 let event = StreamEvent::Token("hello world".to_string());
546 match event {
547 StreamEvent::Token(s) => assert_eq!(s, "hello world"),
548 StreamEvent::Thinking(_) | StreamEvent::ToolCall { .. } => {
549 panic!("expected Token variant")
550 }
551 }
552 }
553
554 #[test]
555 fn stream_event_thinking_carries_reasoning() {
556 let event = StreamEvent::Thinking("let me analyze...".to_string());
557 match event {
558 StreamEvent::Thinking(s) => assert_eq!(s, "let me analyze..."),
559 StreamEvent::Token(_) | StreamEvent::ToolCall { .. } => {
560 panic!("expected Thinking variant")
561 }
562 }
563 }
564}