fix(providers/ollama): strengthen model cache + scope status UI

This commit is contained in:
2025-10-17 03:58:25 +02:00
parent 3f92b7d963
commit 1948ac1284
2 changed files with 277 additions and 48 deletions

View File

@@ -88,6 +88,7 @@ struct ScopeSnapshot {
availability: ScopeAvailability,
last_error: Option<String>,
last_checked: Option<Instant>,
last_success_at: Option<Instant>,
}
impl Default for ScopeSnapshot {
@@ -98,10 +99,29 @@ impl Default for ScopeSnapshot {
availability: ScopeAvailability::Unknown,
last_error: None,
last_checked: None,
last_success_at: None,
}
}
}
impl ScopeSnapshot {
fn is_stale(&self, ttl: Duration) -> bool {
match self.fetched_at {
Some(ts) => ts.elapsed() >= ttl,
None => !self.models.is_empty(),
}
}
fn last_checked_age_secs(&self) -> Option<u64> {
self.last_checked.map(|instant| instant.elapsed().as_secs())
}
fn last_success_age_secs(&self) -> Option<u64> {
self.last_success_at
.map(|instant| instant.elapsed().as_secs())
}
}
#[derive(Debug)]
struct OllamaOptions {
mode: OllamaMode,
@@ -410,22 +430,29 @@ impl OllamaProvider {
return None;
}
entry.fetched_at.and_then(|ts| {
if ts.elapsed() < self.model_cache_ttl {
Some(entry.models.clone())
} else {
None
if entry.models.is_empty() {
return None;
}
})
if let Some(ts) = entry.fetched_at {
if ts.elapsed() < self.model_cache_ttl {
return Some(entry.models.clone());
}
}
// Fallback to last good models even if stale; UI will mark as degraded
Some(entry.models.clone())
})
}
async fn update_scope_success(&self, scope: OllamaMode, models: &[ModelInfo]) {
let mut cache = self.scope_cache.write().await;
let entry = cache.entry(scope).or_default();
let now = Instant::now();
entry.models = models.to_vec();
entry.fetched_at = Some(Instant::now());
entry.last_checked = Some(Instant::now());
entry.fetched_at = Some(now);
entry.last_checked = Some(now);
entry.last_success_at = Some(now);
entry.availability = ScopeAvailability::Available;
entry.last_error = None;
}
@@ -461,6 +488,45 @@ impl OllamaProvider {
}
}
let stale = snapshot.is_stale(self.model_cache_ttl);
let stale_capability = format!(
"scope-status-stale:{}:{}",
scope_key,
if stale { "1" } else { "0" }
);
for model in models.iter_mut() {
if !model
.capabilities
.iter()
.any(|cap| cap == &stale_capability)
{
model.capabilities.push(stale_capability.clone());
}
}
if let Some(age) = snapshot.last_checked_age_secs() {
let age_capability = format!("scope-status-age:{}:{}", scope_key, age);
for model in models.iter_mut() {
if !model.capabilities.iter().any(|cap| cap == &age_capability) {
model.capabilities.push(age_capability.clone());
}
}
}
if let Some(success_age) = snapshot.last_success_age_secs() {
let success_capability =
format!("scope-status-success-age:{}:{}", scope_key, success_age);
for model in models.iter_mut() {
if !model
.capabilities
.iter()
.any(|cap| cap == &success_capability)
{
model.capabilities.push(success_capability.clone());
}
}
}
if let Some(raw_reason) = snapshot.last_error.as_ref() {
let cleaned = raw_reason.replace('\n', " ").trim().to_string();
if !cleaned.is_empty() {
@@ -1658,6 +1724,7 @@ fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() {
let entry = cache.entry(OllamaMode::Cloud).or_default();
entry.availability = ScopeAvailability::Unavailable;
entry.last_error = Some("Cloud endpoint unreachable".to_string());
entry.last_checked = Some(Instant::now());
}
provider.annotate_scope_status(&mut models).await;
@@ -1674,4 +1741,14 @@ fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() {
.iter()
.any(|cap| cap.starts_with("scope-status-message:cloud:"))
);
assert!(
capabilities
.iter()
.any(|cap| cap.starts_with("scope-status-age:cloud:"))
);
assert!(
capabilities
.iter()
.any(|cap| cap == "scope-status-stale:cloud:0")
);
}

View File

@@ -242,6 +242,9 @@ impl Default for ModelAvailabilityState {
pub(crate) struct ScopeStatusEntry {
pub state: ModelAvailabilityState,
pub message: Option<String>,
pub last_checked_secs: Option<u64>,
pub last_success_secs: Option<u64>,
pub is_stale: bool,
}
pub(crate) type ProviderScopeStatus = BTreeMap<ModelScope, ScopeStatusEntry>;
@@ -7785,6 +7788,31 @@ impl ChatApp {
if entry.message.is_none() && !message.trim().is_empty() {
entry.message = Some(message.trim().to_string());
}
} else if let Some(rest) = capability.strip_prefix("scope-status-age:") {
let mut parts = rest.split(':');
let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let value = parts.next().unwrap_or_default();
if let Ok(age) = value.parse::<u64>() {
let scope = Self::scope_from_keyword(&scope_key);
let entry = statuses.entry(scope).or_default();
entry.last_checked_secs = Some(age);
}
} else if let Some(rest) = capability.strip_prefix("scope-status-success-age:") {
let mut parts = rest.split(':');
let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let value = parts.next().unwrap_or_default();
if let Ok(age) = value.parse::<u64>() {
let scope = Self::scope_from_keyword(&scope_key);
let entry = statuses.entry(scope).or_default();
entry.last_success_secs = Some(age);
}
} else if let Some(rest) = capability.strip_prefix("scope-status-stale:") {
let mut parts = rest.split(':');
let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let value = parts.next().unwrap_or_default();
let scope = Self::scope_from_keyword(&scope_key);
let entry = statuses.entry(scope).or_default();
entry.is_stale = matches!(value, "1" | "true" | "True" | "TRUE");
}
}
}
@@ -7800,11 +7828,16 @@ impl ChatApp {
for (scope, entry) in statuses {
if entry.state == ModelAvailabilityState::Unavailable {
let scope_name = Self::scope_display_name(scope);
if let Some(reason) = entry.message.as_ref() {
errors.push(format!("{provider}: {scope_name} unavailable ({reason})"));
if let Some(summary) = Self::scope_status_summary(entry) {
errors.push(format!("{provider}: {scope_name} {summary}"));
} else {
errors.push(format!("{provider}: {scope_name} unavailable"));
}
} else if entry.state == ModelAvailabilityState::Available && entry.is_stale {
let scope_name = Self::scope_display_name(scope);
let summary = Self::scope_status_summary(entry)
.unwrap_or_else(|| "using cached results".to_string());
errors.push(format!("{provider}: {scope_name} degraded ({summary})"));
}
}
}
@@ -7839,26 +7872,94 @@ impl ChatApp {
}
}
fn format_duration_short(seconds: u64) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
if seconds < MINUTE {
format!("{seconds}s")
} else if seconds < HOUR {
format!("{}m", seconds / MINUTE)
} else if seconds < DAY {
let hours = seconds / HOUR;
let minutes = (seconds % HOUR) / MINUTE;
if minutes == 0 {
format!("{hours}h")
} else {
format!("{hours}h{minutes}m")
}
} else {
format!("{}d", seconds / DAY)
}
}
fn scope_status_summary(status: &ScopeStatusEntry) -> Option<String> {
let mut segments: Vec<String> = Vec::new();
if let Some(message) = status.message.as_ref() {
if !message.is_empty() {
segments.push(message.clone());
}
} else if status.state == ModelAvailabilityState::Unavailable {
segments.push("Unavailable".to_string());
} else if status.state == ModelAvailabilityState::Available && status.is_stale {
segments.push("Using cached results".to_string());
}
if let Some(age) = status.last_checked_secs {
segments.push(format!("checked {} ago", Self::format_duration_short(age)));
}
if let Some(success_age) = status.last_success_secs {
if status.state == ModelAvailabilityState::Unavailable {
segments.push(format!(
"last ok {} ago",
Self::format_duration_short(success_age)
));
} else if status.state == ModelAvailabilityState::Available && status.is_stale {
segments.push(format!(
"last refresh {} ago",
Self::format_duration_short(success_age)
));
}
}
if segments.is_empty() {
None
} else {
Some(segments.join(" · "))
}
}
fn scope_header_label(
_provider: &str,
scope: &ModelScope,
status: Option<ModelAvailabilityState>,
status: &ScopeStatusEntry,
filter: FilterMode,
) -> String {
let icon = Self::scope_icon(scope);
let scope_name = Self::scope_display_name(scope);
let mut label = format!("{icon} {scope_name}");
if let Some(state) = status {
match state {
match status.state {
ModelAvailabilityState::Available => {
if matches!(filter, FilterMode::Available) {
label.push_str(" · ✓");
if status.is_stale {
label.push_str(" · ⚠");
}
}
ModelAvailabilityState::Unavailable => {
if status.last_success_secs.is_some() {
label.push_str(" · ⚠");
} else {
label.push_str(" · ✗");
}
}
ModelAvailabilityState::Unavailable => label.push_str(" · ✗"),
ModelAvailabilityState::Unknown => label.push_str(" · ⚙"),
}
if let Some(age) = status.last_checked_secs {
label.push_str(&format!(" · {}", Self::format_duration_short(age)));
}
if matches!(filter, FilterMode::Available) {
@@ -8088,12 +8189,8 @@ impl ChatApp {
.and_then(|map| map.get(&scope))
.cloned()
.unwrap_or_default();
let label = Self::scope_header_label(
provider,
&scope,
Some(status_entry.state),
self.model_filter_mode,
);
let label =
Self::scope_header_label(&scope, &status_entry, self.model_filter_mode);
items.push(ModelSelectorItem::scope(
provider.clone(),
@@ -8102,11 +8199,25 @@ impl ChatApp {
status_entry.state,
));
let scope_allowed = self.filter_scope_allows_models(&scope, status_entry.state);
if status_entry.state != ModelAvailabilityState::Available
|| status_entry.is_stale
|| status_entry.message.is_some()
{
if let Some(summary) = Self::scope_status_summary(&status_entry) {
rendered_body = true;
items.push(ModelSelectorItem::empty(
provider.clone(),
Some(summary),
Some(status_entry.state),
));
}
}
let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry);
if deduped.is_empty() {
if !scope_allowed {
let message = self.scope_filter_message(&scope, status_entry.state);
let message = self.scope_filter_message(&scope, &status_entry);
if let Some(msg) = message {
rendered_body = true;
items.push(ModelSelectorItem::empty(
@@ -8141,7 +8252,7 @@ impl ChatApp {
}
if !scope_allowed {
let message = self.scope_filter_message(&scope, status_entry.state);
let message = self.scope_filter_message(&scope, &status_entry);
if let Some(msg) = message {
rendered_body = true;
items.push(ModelSelectorItem::empty(
@@ -8184,7 +8295,11 @@ impl ChatApp {
match entry.state {
ModelAvailabilityState::Unavailable => return ProviderStatus::Unavailable,
ModelAvailabilityState::Unknown => saw_unknown = true,
ModelAvailabilityState::Available => {}
ModelAvailabilityState::Available => {
if entry.is_stale {
saw_unknown = true;
}
}
}
}
if saw_unknown {
@@ -8224,13 +8339,11 @@ impl ChatApp {
}
}
fn filter_scope_allows_models(
&self,
scope: &ModelScope,
status: ModelAvailabilityState,
) -> bool {
fn filter_scope_allows_models(&self, scope: &ModelScope, status: &ScopeStatusEntry) -> bool {
match self.model_filter_mode {
FilterMode::Available => status == ModelAvailabilityState::Available,
FilterMode::Available => {
status.state == ModelAvailabilityState::Available && !status.is_stale
}
FilterMode::LocalOnly => matches!(scope, ModelScope::Local),
FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud),
FilterMode::All => true,
@@ -8240,11 +8353,14 @@ impl ChatApp {
fn scope_filter_message(
&self,
scope: &ModelScope,
status: ModelAvailabilityState,
status: &ScopeStatusEntry,
) -> Option<String> {
match self.model_filter_mode {
FilterMode::Available => match status {
ModelAvailabilityState::Available => None,
FilterMode::Available => {
if status.state == ModelAvailabilityState::Available && !status.is_stale {
return None;
}
Self::scope_status_summary(status).or_else(|| match status.state {
ModelAvailabilityState::Unavailable => {
Some(format!("{} unavailable", Self::scope_display_name(scope)))
}
@@ -8252,10 +8368,17 @@ impl ChatApp {
"{} setup required",
Self::scope_display_name(scope)
)),
},
ModelAvailabilityState::Available => Some(format!(
"{} cached results",
Self::scope_display_name(scope)
)),
})
}
FilterMode::LocalOnly | FilterMode::CloudOnly => {
if status == ModelAvailabilityState::Unavailable {
if status.state == ModelAvailabilityState::Unavailable {
Self::scope_status_summary(status).or_else(|| {
Some(format!("{} unavailable", Self::scope_display_name(scope)))
})
} else {
None
}
@@ -11245,7 +11368,7 @@ fn normalize_cloud_endpoint(endpoint: &str) -> String {
#[cfg(test)]
mod tests {
use super::{ChatApp, render_markdown_lines, wrap_unicode};
use super::{ChatApp, ModelAvailabilityState, ModelScope, render_markdown_lines, wrap_unicode};
use crate::app::UiRuntime;
use futures_util::{future, stream};
use owlen_core::{
@@ -11337,6 +11460,35 @@ mod tests {
assert!(wrapped.is_empty());
}
#[test]
fn extract_scope_status_includes_extended_metadata() {
let models = vec![ModelInfo {
id: "demo".to_string(),
name: "Demo".to_string(),
description: None,
provider: "demo".to_string(),
context_window: None,
capabilities: vec![
"scope:local".to_string(),
"scope-status:local:available".to_string(),
"scope-status-age:local:30".to_string(),
"scope-status-success-age:local:5".to_string(),
"scope-status-stale:local:1".to_string(),
"scope-status-message:local:Cached copy".to_string(),
],
supports_tools: false,
}];
let statuses = ChatApp::extract_scope_status(&models);
let entry = statuses.get(&ModelScope::Local).expect("local scope entry");
assert_eq!(entry.state, ModelAvailabilityState::Available);
assert_eq!(entry.last_checked_secs, Some(30));
assert_eq!(entry.last_success_secs, Some(5));
assert!(entry.is_stale);
assert_eq!(entry.message.as_deref(), Some("Cached copy"));
}
struct StubProvider;
impl LlmProvider for StubProvider {