use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use crate::core::error::ProviderError;
use crate::llm::{Provider, ProviderEvent, ProviderResponse, Model, ModelCapabilities};
use crate::storage::Message;
use crate::tools::Tool;
use super::openai_compatible::{OpenAICompatibleProvider, CompatibleProviderConfig};
#[derive(Debug, Clone)]
pub struct LocalProvider {
inner: OpenAICompatibleProvider,
endpoint: String,
discovered_models: Vec<LocalModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalModel {
pub id: String,
pub object: String,
#[serde(rename = "type")]
pub model_type: Option<String>,
pub publisher: Option<String>,
pub arch: Option<String>,
pub compatibility_type: Option<String>,
pub quantization: Option<String>,
pub state: Option<String>,
pub max_context_length: Option<u64>,
pub loaded_context_length: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct LocalModelList {
data: Vec<LocalModel>,
}
#[derive(Debug, Clone)]
pub struct LocalProviderConfig {
pub endpoint: String,
pub api_key: Option<String>,
pub timeout_seconds: u64,
pub discovery_paths: Vec<String>,
}
impl Default for LocalProviderConfig {
fn default() -> Self {
Self {
endpoint: env::var("LOCAL_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:1234".to_string()),
api_key: env::var("LOCAL_API_KEY").ok(),
timeout_seconds: 60,
discovery_paths: vec![
"v1/models".to_string(), "api/v0/models".to_string(), "api/v1/models".to_string(), "v1/internal/model/list".to_string(), ],
}
}
}
impl LocalProvider {
pub async fn new(config: LocalProviderConfig) -> Result<Self, ProviderError> {
info!("Initializing local provider with endpoint: {}", config.endpoint);
let discovered_models = Self::discover_models(&config).await?;
if discovered_models.is_empty() {
warn!("No local models discovered at endpoint: {}", config.endpoint);
return Err(ProviderError::Configuration(
"No local models found. Ensure your local AI server is running.".to_string()
));
}
info!("Discovered {} local models", discovered_models.len());
for model in &discovered_models {
debug!("Found model: {} (state: {:?})", model.id, model.state);
}
let selected_model = discovered_models
.iter()
.find(|m| m.state.as_deref() == Some("loaded"))
.or_else(|| discovered_models.first())
.ok_or_else(|| ProviderError::Configuration("No models available".to_string()))?;
let model = Self::convert_local_model(selected_model);
let compat_config = CompatibleProviderConfig {
name: "local".to_string(),
base_url: format!("{}/v1", config.endpoint.trim_end_matches('/')),
supports_tools: true,
supports_streaming: true,
requires_auth: config.api_key.is_some(),
custom_headers: vec![],
};
let api_key = config.api_key.unwrap_or_else(|| "dummy".to_string());
let inner = OpenAICompatibleProvider::new(compat_config, api_key, model, None, None)?;
Ok(Self {
inner,
endpoint: config.endpoint,
discovered_models,
})
}
pub async fn with_model(config: LocalProviderConfig, model_id: &str) -> Result<Self, ProviderError> {
let discovered_models = Self::discover_models(&config).await?;
let selected_model = discovered_models
.iter()
.find(|m| m.id == model_id)
.ok_or_else(|| ProviderError::Configuration(
format!("Model '{}' not found in discovered models", model_id)
))?;
let model = Self::convert_local_model(selected_model);
let compat_config = CompatibleProviderConfig {
name: "local".to_string(),
base_url: format!("{}/v1", config.endpoint.trim_end_matches('/')),
supports_tools: true,
supports_streaming: true,
requires_auth: config.api_key.is_some(),
custom_headers: vec![],
};
let api_key = config.api_key.unwrap_or_else(|| "dummy".to_string());
let inner = OpenAICompatibleProvider::new(compat_config, api_key, model, None, None)?;
Ok(Self {
inner,
endpoint: config.endpoint,
discovered_models,
})
}
async fn discover_models(config: &LocalProviderConfig) -> Result<Vec<LocalModel>, ProviderError> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(config.timeout_seconds))
.build()
.map_err(|e| ProviderError::Configuration(format!("Failed to create HTTP client: {}", e)))?;
for path in &config.discovery_paths {
let url = format!("{}/{}", config.endpoint.trim_end_matches('/'), path);
debug!("Trying model discovery at: {}", url);
match Self::try_discover_at_endpoint(&client, &url, config.api_key.as_deref()).await {
Ok(models) if !models.is_empty() => {
info!("Successfully discovered {} models at {}", models.len(), url);
return Ok(models);
}
Ok(_) => {
debug!("No models found at {}", url);
}
Err(e) => {
debug!("Failed to discover models at {}: {}", url, e);
}
}
}
Err(ProviderError::Configuration(
format!("No models discovered at any endpoint. Tried: {:?}", config.discovery_paths)
))
}
async fn try_discover_at_endpoint(
client: &Client,
url: &str,
api_key: Option<&str>,
) -> Result<Vec<LocalModel>, ProviderError> {
let mut request = client.get(url);
if let Some(key) = api_key {
request = request.header("Authorization", format!("Bearer {}", key));
}
let response = request
.send()
.await
.map_err(|e| ProviderError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(ProviderError::ApiError(format!(
"Model discovery failed with status: {}",
response.status()
)));
}
let model_list: LocalModelList = response
.json()
.await
.map_err(|e| ProviderError::ResponseParsing(e.to_string()))?;
let filtered_models = if url.contains("api/v0/models") {
model_list.data.into_iter()
.filter(|m| m.object == "model" && m.model_type.as_deref() == Some("llm"))
.collect()
} else {
model_list.data
};
Ok(filtered_models)
}
fn convert_local_model(local_model: &LocalModel) -> Model {
let context_length = local_model.loaded_context_length
.or(local_model.max_context_length)
.unwrap_or(4096) as u32;
Model {
id: format!("local.{}", local_model.id),
name: Self::friendly_model_name(&local_model.id),
provider: "local".to_string(),
context_length,
max_output_tokens: context_length / 2, supports_tools: true,
supports_streaming: true,
supports_vision: false, cost_per_input_token: 0.0, cost_per_output_token: 0.0,
capabilities: ModelCapabilities::default(),
}
}
fn friendly_model_name(model_id: &str) -> String {
let mut name = model_id.to_string();
if let Some(last_slash) = name.rfind('/') {
name = name[last_slash + 1..].to_string();
}
for suffix in &[".gguf", ".bin", ".safetensors"] {
if name.ends_with(suffix) {
name = name[..name.len() - suffix.len()].to_string();
break;
}
}
name = name.replace(['_', '-'], " ");
name.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn discovered_models(&self) -> &[LocalModel] {
&self.discovered_models
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
}
#[async_trait]
impl Provider for LocalProvider {
async fn send_messages(
&self,
messages: Vec<Message>,
tools: Vec<Box<dyn Tool>>,
) -> Result<ProviderResponse, ProviderError> {
self.inner.send_messages(messages, tools).await
}
async fn stream_response(
&self,
messages: Vec<Message>,
tools: Vec<Box<dyn Tool>>,
) -> Result<mpsc::Receiver<Result<ProviderEvent, ProviderError>>, ProviderError> {
self.inner.stream_response(messages, tools).await
}
fn model(&self) -> &Model {
self.inner.model()
}
fn name(&self) -> &str {
"local"
}
async fn is_available(&self) -> bool {
let client = Client::new();
let health_url = format!("{}/v1/models", self.endpoint);
match client.get(&health_url).send().await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
}