use crate::error::LlmError;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
pub content: String,
pub model: String,
pub usage: Option<TokenUsage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_input_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<u32>,
pub completion_tokens: u32,
pub total_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_count: Option<u32>,
#[serde(default)]
pub prompt_eval_nanos: u64,
#[serde(default)]
pub eval_nanos: u64,
#[serde(default)]
pub total_nanos: u64,
#[serde(default)]
pub load_nanos: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advisor: Option<AdvisorTokenUsage>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdvisorTokenUsage {
pub model: String,
pub calls: u32,
pub input_tokens: u32,
pub cache_read_tokens: u32,
pub cache_write_tokens: u32,
pub output_tokens: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub name: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub modified_at: String,
#[serde(default)]
pub digest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone)]
pub enum StreamEvent {
Token(String),
Thinking(String),
ToolCall {
id: String,
name: String,
arguments_json: String,
},
}
#[async_trait]
pub trait LlmClient: Send + Sync {
async fn chat(
&self,
messages: &[Message],
model: Option<&str>,
tools: Option<Vec<ToolDefinition>>,
) -> Result<ChatResponse, LlmError>;
async fn chat_stream(
&self,
messages: &[Message],
model: Option<&str>,
event_tx: Option<mpsc::UnboundedSender<StreamEvent>>,
tools: Option<Vec<ToolDefinition>>,
) -> Result<ChatResponse, LlmError> {
let response = self.chat(messages, model, tools).await?;
if let Some(tx) = event_tx {
let _ = tx.send(StreamEvent::Token(response.content.clone()));
}
Ok(response)
}
async fn chat_stream_with_params(
&self,
messages: &[Message],
model: Option<&str>,
event_tx: Option<mpsc::UnboundedSender<StreamEvent>>,
tools: Option<Vec<ToolDefinition>>,
_max_completion_tokens: Option<u32>,
_temperature: Option<f32>,
) -> Result<ChatResponse, LlmError> {
self.chat_stream(messages, model, event_tx, tools).await
}
async fn chat_json(
&self,
messages: &[Message],
model: Option<&str>,
tools: Option<Vec<ToolDefinition>>,
) -> Result<serde_json::Value, LlmError>;
async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError>;
async fn health_check(&self) -> Result<bool, LlmError>;
}
pub trait LlmClientFactory: Send + Sync {
fn name(&self) -> &str;
fn display_name(&self) -> &str;
fn create(&self, config: &HashMap<String, String>) -> Result<Arc<dyn LlmClient>, LlmError>;
fn validate_config(&self, config: &HashMap<String, String>) -> Vec<String>;
fn required_config_keys(&self) -> Vec<&str>;
}
pub struct LlmRegistry {
providers: HashMap<String, Arc<dyn LlmClientFactory>>,
}
impl LlmRegistry {
#[must_use]
pub fn new() -> Self {
Self { providers: HashMap::new() }
}
pub fn register(&mut self, name: String, factory: Arc<dyn LlmClientFactory>) {
self.providers.insert(name, factory);
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&Arc<dyn LlmClientFactory>> {
self.providers.get(name)
}
#[must_use]
pub fn list(&self) -> Vec<(&str, &str)> {
self.providers.values().map(|f| (f.name(), f.display_name())).collect()
}
pub fn create_client(
&self,
provider_name: &str,
config: &HashMap<String, String>,
) -> Result<Arc<dyn LlmClient>, LlmError> {
let factory = self
.providers
.get(provider_name)
.ok_or_else(|| LlmError::UnsupportedProvider(provider_name.to_string()))?;
factory.create(config)
}
}
impl Default for LlmRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_usage_round_trips_cache_writes_and_the_advisor() {
let usage = TokenUsage {
prompt_tokens: 4_020,
cached_input_tokens: Some(3_000),
cache_write_tokens: Some(900),
completion_tokens: 45,
total_tokens: 4_065,
tool_call_count: None,
prompt_eval_nanos: 0,
eval_nanos: 0,
total_nanos: 7,
load_nanos: 0,
advisor: Some(AdvisorTokenUsage {
model: "claude-opus-5-5".to_string(),
calls: 2,
input_tokens: 900,
cache_read_tokens: 300,
cache_write_tokens: 50,
output_tokens: 700,
}),
};
let json = serde_json::to_string(&usage).expect("serializes");
let back: TokenUsage = serde_json::from_str(&json).expect("reads back");
assert_eq!(back.cache_write_tokens, Some(900));
assert_eq!(back.advisor, usage.advisor);
}
#[test]
fn a_provider_that_reports_neither_leaves_them_out_of_the_json() {
let usage: TokenUsage =
serde_json::from_str(r#"{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}"#)
.expect("the older shape still reads");
assert_eq!(usage.cache_write_tokens, None);
assert!(usage.advisor.is_none());
let json = serde_json::to_value(&usage).expect("serializes");
assert!(json.get("cache_write_tokens").is_none(), "{json}");
assert!(json.get("advisor").is_none(), "{json}");
}
struct MockLlmClient;
#[async_trait]
impl LlmClient for MockLlmClient {
async fn chat(
&self,
_messages: &[Message],
_model: Option<&str>,
_tools: Option<Vec<ToolDefinition>>,
) -> Result<ChatResponse, LlmError> {
Ok(ChatResponse {
content: "test response".to_string(),
model: "test-model".to_string(),
usage: None,
})
}
async fn chat_json(
&self,
_messages: &[Message],
_model: Option<&str>,
_tools: Option<Vec<ToolDefinition>>,
) -> Result<serde_json::Value, LlmError> {
Ok(serde_json::json!({"response": "test"}))
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
Ok(vec![ModelInfo {
name: "test-model".to_string(),
size: 0,
modified_at: String::new(),
digest: String::new(),
}])
}
async fn health_check(&self) -> Result<bool, LlmError> {
Ok(true)
}
}
struct MockLlmFactory;
impl LlmClientFactory for MockLlmFactory {
fn name(&self) -> &'static str {
"mock"
}
fn display_name(&self) -> &'static str {
"Mock Provider"
}
fn create(
&self,
_config: &HashMap<String, String>,
) -> Result<Arc<dyn LlmClient>, LlmError> {
Ok(Arc::new(MockLlmClient))
}
fn validate_config(&self, _config: &HashMap<String, String>) -> Vec<String> {
Vec::new()
}
fn required_config_keys(&self) -> Vec<&str> {
vec!["base_url"]
}
}
#[test]
fn llm_registry_resolves_registered_provider() {
let mut registry = LlmRegistry::new();
registry.register("mock".to_string(), Arc::new(MockLlmFactory));
assert!(registry.get("mock").is_some());
}
#[test]
fn llm_registry_create_client_delegates_to_factory() {
let mut registry = LlmRegistry::new();
registry.register("mock".to_string(), Arc::new(MockLlmFactory));
let config = HashMap::from([("base_url".to_string(), "http://localhost".to_string())]);
let client = registry.create_client("mock", &config);
assert!(client.is_ok());
}
#[test]
fn llm_registry_unknown_provider_returns_error() {
let registry = LlmRegistry::new();
let config = HashMap::new();
let result = registry.create_client("nonexistent", &config);
assert!(result.is_err());
let err = result.err().unwrap();
assert!(matches!(err, LlmError::UnsupportedProvider(_)));
}
#[test]
fn llm_registry_list_shows_all_providers() {
let mut registry = LlmRegistry::new();
registry.register("mock1".to_string(), Arc::new(MockLlmFactory));
registry.register("mock2".to_string(), Arc::new(MockLlmFactory));
assert_eq!(registry.list().len(), 2);
}
#[tokio::test]
async fn chat_stream_default_sends_full_content_as_single_token() {
let client = MockLlmClient;
let (tx, mut rx) = mpsc::unbounded_channel();
let messages = vec![Message { role: "user".to_string(), content: "hello".to_string() }];
let response = client.chat_stream(&messages, None, Some(tx), None).await.unwrap();
assert_eq!(response.content, "test response");
let event = rx.recv().await.unwrap();
assert!(matches!(event, StreamEvent::Token(ref s) if s == "test response"));
assert!(rx.recv().await.is_none());
}
#[tokio::test]
async fn chat_stream_with_none_tx_returns_response_without_events() {
let client = MockLlmClient;
let messages = vec![Message { role: "user".to_string(), content: "hello".to_string() }];
let response = client.chat_stream(&messages, None, None, None).await.unwrap();
assert_eq!(response.content, "test response");
}
#[test]
fn stream_event_token_carries_content() {
let event = StreamEvent::Token("hello world".to_string());
match event {
StreamEvent::Token(s) => assert_eq!(s, "hello world"),
StreamEvent::Thinking(_) | StreamEvent::ToolCall { .. } => {
panic!("expected Token variant")
}
}
}
#[test]
fn stream_event_thinking_carries_reasoning() {
let event = StreamEvent::Thinking("let me analyze...".to_string());
match event {
StreamEvent::Thinking(s) => assert_eq!(s, "let me analyze..."),
StreamEvent::Token(_) | StreamEvent::ToolCall { .. } => {
panic!("expected Thinking variant")
}
}
}
}