feat(model): add rich model metadata, caching, and UI panel for inspection

Introduce `DetailedModelInfo` and `ModelInfoRetrievalError` structs for richer model data.
Add `ModelDetailsCache` with TTL‑based storage and async API for get/insert/invalidate.
Extend `OllamaProvider` to fetch, cache, refresh, and list detailed model info.
Expose model‑detail methods in `Session` for on‑demand and bulk retrieval.
Add `ModelInfoPanel` widget to display detailed info with scrolling support.
Update TUI rendering to show the panel, compute viewport height, and render model selector labels with parameters, size, and context length.
Adjust imports and module re‑exports accordingly.
This commit is contained in:
2025-10-12 09:45:16 +02:00
parent fab63d224b
commit c2f5ccea3b
10 changed files with 1168 additions and 14 deletions

View File

@@ -27,7 +27,7 @@ use uuid::Uuid;
use crate::{
config::GeneralSettings,
mcp::McpToolDescriptor,
model::ModelManager,
model::{DetailedModelInfo, ModelDetailsCache, ModelManager},
provider::{LLMProvider, ProviderConfig},
types::{
ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage, ToolCall,
@@ -88,6 +88,7 @@ pub struct OllamaProvider {
http_client: Client,
base_url: String,
model_manager: ModelManager,
model_details_cache: ModelDetailsCache,
}
impl OllamaProvider {
@@ -191,6 +192,7 @@ impl OllamaProvider {
http_client,
base_url: base_url.trim_end_matches('/').to_string(),
model_manager: ModelManager::new(model_cache_ttl),
model_details_cache: ModelDetailsCache::new(model_cache_ttl),
})
}
@@ -198,6 +200,84 @@ impl OllamaProvider {
build_api_endpoint(&self.base_url, endpoint)
}
/// Attempt to resolve detailed model information for the given model, using the local cache when possible.
pub async fn get_model_info(&self, model_name: &str) -> Result<DetailedModelInfo> {
if let Some(info) = self.model_details_cache.get(model_name).await {
return Ok(info);
}
self.fetch_and_cache_model_info(model_name, None).await
}
/// Force-refresh model information for the specified model.
pub async fn refresh_model_info(&self, model_name: &str) -> Result<DetailedModelInfo> {
self.model_details_cache.invalidate(model_name).await;
self.fetch_and_cache_model_info(model_name, None).await
}
/// Retrieve detailed information for all locally available models.
pub async fn get_all_models_info(&self) -> Result<Vec<DetailedModelInfo>> {
let models = self
.client
.list_local_models()
.await
.map_err(|err| self.map_ollama_error("list models", err, None))?;
let mut details = Vec::with_capacity(models.len());
for local in &models {
match self
.fetch_and_cache_model_info(&local.name, Some(local))
.await
{
Ok(info) => details.push(info),
Err(err) => warn!("Failed to gather model info for '{}': {}", local.name, err),
}
}
Ok(details)
}
/// Return any cached model information without touching the Ollama daemon.
pub async fn cached_model_info(&self) -> Vec<DetailedModelInfo> {
self.model_details_cache.cached().await
}
/// Remove a single model's cached information.
pub async fn invalidate_model_info(&self, model_name: &str) {
self.model_details_cache.invalidate(model_name).await;
}
/// Clear the entire model information cache.
pub async fn clear_model_info_cache(&self) {
self.model_details_cache.invalidate_all().await;
}
async fn fetch_and_cache_model_info(
&self,
model_name: &str,
local: Option<&LocalModel>,
) -> Result<DetailedModelInfo> {
let detail = self
.client
.show_model_info(model_name.to_string())
.await
.map_err(|err| self.map_ollama_error("show_model_info", err, Some(model_name)))?;
let local_owned = if let Some(local) = local {
Some(local.clone())
} else {
let models = self
.client
.list_local_models()
.await
.map_err(|err| self.map_ollama_error("list models", err, None))?;
models.into_iter().find(|m| m.name == model_name)
};
let detailed =
Self::convert_detailed_model_info(self.mode, model_name, local_owned.as_ref(), &detail);
self.model_details_cache.insert(detailed.clone()).await;
Ok(detailed)
}
fn prepare_chat_request(
&self,
model: String,
@@ -239,12 +319,24 @@ impl OllamaProvider {
.map_err(|err| self.map_ollama_error("list models", err, None))?;
let client = self.client.clone();
let cache = self.model_details_cache.clone();
let mode = self.mode;
let fetched = join_all(models.into_iter().map(|local| {
let client = client.clone();
let cache = cache.clone();
async move {
let name = local.name.clone();
let detail = match client.show_model_info(name.clone()).await {
Ok(info) => Some(info),
Ok(info) => {
let detailed = OllamaProvider::convert_detailed_model_info(
mode,
&name,
Some(&local),
&info,
);
cache.insert(detailed).await;
Some(info)
}
Err(err) => {
debug!("Failed to fetch Ollama model info for '{name}': {err}");
None
@@ -261,6 +353,85 @@ impl OllamaProvider {
.collect())
}
fn convert_detailed_model_info(
mode: OllamaMode,
model_name: &str,
local: Option<&LocalModel>,
detail: &OllamaModelInfo,
) -> DetailedModelInfo {
let map = &detail.model_info;
let architecture =
pick_first_string(map, &["architecture", "model_format", "model_type", "arch"]);
let parameters = non_empty(detail.parameters.clone())
.or_else(|| pick_first_string(map, &["parameters"]));
let parameter_size = pick_first_string(map, &["parameter_size"]);
let context_length = pick_first_u64(map, &["context_length", "num_ctx", "max_context"]);
let embedding_length = pick_first_u64(map, &["embedding_length"]);
let quantization =
pick_first_string(map, &["quantization_level", "quantization", "quantize"]);
let family = pick_first_string(map, &["family", "model_family"]);
let mut families = pick_string_list(map, &["families", "model_families"]);
if families.is_empty() {
if let Some(single) = family.clone() {
families.push(single);
}
}
let system = pick_first_string(map, &["system"]);
let mut modified_at = local
.and_then(|entry| non_empty(entry.modified_at.clone()))
.or_else(|| pick_first_string(map, &["modified_at", "created_at"]));
if modified_at.is_none() && mode == OllamaMode::Cloud {
modified_at = pick_first_string(map, &["updated_at"]);
}
let size = local
.and_then(|entry| {
if entry.size > 0 {
Some(entry.size)
} else {
None
}
})
.or_else(|| pick_first_u64(map, &["size", "model_size", "download_size"]));
let digest = pick_first_string(map, &["digest", "sha256", "checksum"]);
let mut info = DetailedModelInfo {
name: model_name.to_string(),
architecture,
parameters,
context_length,
embedding_length,
quantization,
family,
families,
parameter_size,
template: non_empty(detail.template.clone()),
system,
license: non_empty(detail.license.clone()),
modelfile: non_empty(detail.modelfile.clone()),
modified_at,
size,
digest,
};
if info.parameter_size.is_none() {
info.parameter_size = info.parameters.clone();
}
info.with_normalised_strings()
}
fn convert_model(&self, model: LocalModel, detail: Option<OllamaModelInfo>) -> ModelInfo {
let scope = match self.mode {
OllamaMode::Local => "local",
@@ -682,6 +853,93 @@ fn build_model_description(scope: &str, detail: Option<&OllamaModelInfo>) -> Str
format!("Ollama ({scope}) model")
}
fn non_empty(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(value)
}
}
fn pick_first_string(map: &JsonMap<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.filter_map(|key| map.get(*key))
.find_map(value_to_string)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn pick_first_u64(map: &JsonMap<String, Value>, keys: &[&str]) -> Option<u64> {
keys.iter()
.filter_map(|key| map.get(*key))
.find_map(value_to_u64)
}
fn pick_string_list(map: &JsonMap<String, Value>, keys: &[&str]) -> Vec<String> {
for key in keys {
if let Some(value) = map.get(*key) {
match value {
Value::Array(items) => {
let collected: Vec<String> = items
.iter()
.filter_map(value_to_string)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !collected.is_empty() {
return collected;
}
}
Value::String(text) => {
let collected: Vec<String> = text
.split(',')
.map(|part| part.trim())
.filter(|part| !part.is_empty())
.map(|part| part.to_string())
.collect();
if !collected.is_empty() {
return collected;
}
}
_ => {}
}
}
}
Vec::new()
}
fn value_to_string(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Number(num) => Some(num.to_string()),
Value::Bool(flag) => Some(flag.to_string()),
_ => None,
}
}
fn value_to_u64(value: &Value) -> Option<u64> {
match value {
Value::Number(num) => {
if let Some(v) = num.as_u64() {
Some(v)
} else if let Some(v) = num.as_i64() {
v.try_into().ok()
} else if let Some(v) = num.as_f64() {
if v >= 0.0 {
Some(v as u64)
} else {
None
}
} else {
None
}
}
Value::String(text) => text.trim().parse::<u64>().ok(),
_ => None,
}
}
fn env_var_non_empty(name: &str) -> Option<String> {
env::var(name)
.ok()