fix(providers/ollama): strengthen model cache + scope status UI
This commit is contained in:
@@ -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 entry.models.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(ts) = entry.fetched_at {
|
||||
if ts.elapsed() < self.model_cache_ttl {
|
||||
Some(entry.models.clone())
|
||||
} else {
|
||||
None
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
ModelAvailabilityState::Available => {
|
||||
if matches!(filter, FilterMode::Available) {
|
||||
label.push_str(" · ✓");
|
||||
}
|
||||
match status.state {
|
||||
ModelAvailabilityState::Available => {
|
||||
label.push_str(" · ✓");
|
||||
if status.is_stale {
|
||||
label.push_str(" · ⚠");
|
||||
}
|
||||
ModelAvailabilityState::Unavailable => label.push_str(" · ✗"),
|
||||
ModelAvailabilityState::Unknown => label.push_str(" · ⚙"),
|
||||
}
|
||||
ModelAvailabilityState::Unavailable => {
|
||||
if status.last_success_secs.is_some() {
|
||||
label.push_str(" · ⚠");
|
||||
} else {
|
||||
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,22 +8353,32 @@ 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,
|
||||
ModelAvailabilityState::Unavailable => {
|
||||
Some(format!("{} unavailable", Self::scope_display_name(scope)))
|
||||
FilterMode::Available => {
|
||||
if status.state == ModelAvailabilityState::Available && !status.is_stale {
|
||||
return None;
|
||||
}
|
||||
ModelAvailabilityState::Unknown => Some(format!(
|
||||
"{} setup required",
|
||||
Self::scope_display_name(scope)
|
||||
)),
|
||||
},
|
||||
Self::scope_status_summary(status).or_else(|| match status.state {
|
||||
ModelAvailabilityState::Unavailable => {
|
||||
Some(format!("{} unavailable", Self::scope_display_name(scope)))
|
||||
}
|
||||
ModelAvailabilityState::Unknown => Some(format!(
|
||||
"{} 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 {
|
||||
Some(format!("{} unavailable", Self::scope_display_name(scope)))
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user