fix(providers/ollama): strengthen model cache + scope status UI
This commit is contained in:
@@ -88,6 +88,7 @@ struct ScopeSnapshot {
|
|||||||
availability: ScopeAvailability,
|
availability: ScopeAvailability,
|
||||||
last_error: Option<String>,
|
last_error: Option<String>,
|
||||||
last_checked: Option<Instant>,
|
last_checked: Option<Instant>,
|
||||||
|
last_success_at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ScopeSnapshot {
|
impl Default for ScopeSnapshot {
|
||||||
@@ -98,10 +99,29 @@ impl Default for ScopeSnapshot {
|
|||||||
availability: ScopeAvailability::Unknown,
|
availability: ScopeAvailability::Unknown,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
last_checked: 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)]
|
#[derive(Debug)]
|
||||||
struct OllamaOptions {
|
struct OllamaOptions {
|
||||||
mode: OllamaMode,
|
mode: OllamaMode,
|
||||||
@@ -410,22 +430,29 @@ impl OllamaProvider {
|
|||||||
return None;
|
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 {
|
if ts.elapsed() < self.model_cache_ttl {
|
||||||
Some(entry.models.clone())
|
return Some(entry.models.clone());
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// 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]) {
|
async fn update_scope_success(&self, scope: OllamaMode, models: &[ModelInfo]) {
|
||||||
let mut cache = self.scope_cache.write().await;
|
let mut cache = self.scope_cache.write().await;
|
||||||
let entry = cache.entry(scope).or_default();
|
let entry = cache.entry(scope).or_default();
|
||||||
|
let now = Instant::now();
|
||||||
entry.models = models.to_vec();
|
entry.models = models.to_vec();
|
||||||
entry.fetched_at = Some(Instant::now());
|
entry.fetched_at = Some(now);
|
||||||
entry.last_checked = Some(Instant::now());
|
entry.last_checked = Some(now);
|
||||||
|
entry.last_success_at = Some(now);
|
||||||
entry.availability = ScopeAvailability::Available;
|
entry.availability = ScopeAvailability::Available;
|
||||||
entry.last_error = None;
|
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() {
|
if let Some(raw_reason) = snapshot.last_error.as_ref() {
|
||||||
let cleaned = raw_reason.replace('\n', " ").trim().to_string();
|
let cleaned = raw_reason.replace('\n', " ").trim().to_string();
|
||||||
if !cleaned.is_empty() {
|
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();
|
let entry = cache.entry(OllamaMode::Cloud).or_default();
|
||||||
entry.availability = ScopeAvailability::Unavailable;
|
entry.availability = ScopeAvailability::Unavailable;
|
||||||
entry.last_error = Some("Cloud endpoint unreachable".to_string());
|
entry.last_error = Some("Cloud endpoint unreachable".to_string());
|
||||||
|
entry.last_checked = Some(Instant::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.annotate_scope_status(&mut models).await;
|
provider.annotate_scope_status(&mut models).await;
|
||||||
@@ -1674,4 +1741,14 @@ fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|cap| cap.starts_with("scope-status-message:cloud:"))
|
.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(crate) struct ScopeStatusEntry {
|
||||||
pub state: ModelAvailabilityState,
|
pub state: ModelAvailabilityState,
|
||||||
pub message: Option<String>,
|
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>;
|
pub(crate) type ProviderScopeStatus = BTreeMap<ModelScope, ScopeStatusEntry>;
|
||||||
@@ -7785,6 +7788,31 @@ impl ChatApp {
|
|||||||
if entry.message.is_none() && !message.trim().is_empty() {
|
if entry.message.is_none() && !message.trim().is_empty() {
|
||||||
entry.message = Some(message.trim().to_string());
|
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 {
|
for (scope, entry) in statuses {
|
||||||
if entry.state == ModelAvailabilityState::Unavailable {
|
if entry.state == ModelAvailabilityState::Unavailable {
|
||||||
let scope_name = Self::scope_display_name(scope);
|
let scope_name = Self::scope_display_name(scope);
|
||||||
if let Some(reason) = entry.message.as_ref() {
|
if let Some(summary) = Self::scope_status_summary(entry) {
|
||||||
errors.push(format!("{provider}: {scope_name} unavailable ({reason})"));
|
errors.push(format!("{provider}: {scope_name} {summary}"));
|
||||||
} else {
|
} else {
|
||||||
errors.push(format!("{provider}: {scope_name} unavailable"));
|
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(
|
fn scope_header_label(
|
||||||
_provider: &str,
|
|
||||||
scope: &ModelScope,
|
scope: &ModelScope,
|
||||||
status: Option<ModelAvailabilityState>,
|
status: &ScopeStatusEntry,
|
||||||
filter: FilterMode,
|
filter: FilterMode,
|
||||||
) -> String {
|
) -> String {
|
||||||
let icon = Self::scope_icon(scope);
|
let icon = Self::scope_icon(scope);
|
||||||
let scope_name = Self::scope_display_name(scope);
|
let scope_name = Self::scope_display_name(scope);
|
||||||
let mut label = format!("{icon} {scope_name}");
|
let mut label = format!("{icon} {scope_name}");
|
||||||
|
|
||||||
if let Some(state) = status {
|
match status.state {
|
||||||
match state {
|
ModelAvailabilityState::Available => {
|
||||||
ModelAvailabilityState::Available => {
|
label.push_str(" · ✓");
|
||||||
if matches!(filter, FilterMode::Available) {
|
if status.is_stale {
|
||||||
label.push_str(" · ✓");
|
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) {
|
if matches!(filter, FilterMode::Available) {
|
||||||
@@ -8088,12 +8189,8 @@ impl ChatApp {
|
|||||||
.and_then(|map| map.get(&scope))
|
.and_then(|map| map.get(&scope))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let label = Self::scope_header_label(
|
let label =
|
||||||
provider,
|
Self::scope_header_label(&scope, &status_entry, self.model_filter_mode);
|
||||||
&scope,
|
|
||||||
Some(status_entry.state),
|
|
||||||
self.model_filter_mode,
|
|
||||||
);
|
|
||||||
|
|
||||||
items.push(ModelSelectorItem::scope(
|
items.push(ModelSelectorItem::scope(
|
||||||
provider.clone(),
|
provider.clone(),
|
||||||
@@ -8102,11 +8199,25 @@ impl ChatApp {
|
|||||||
status_entry.state,
|
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 deduped.is_empty() {
|
||||||
if !scope_allowed {
|
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 {
|
if let Some(msg) = message {
|
||||||
rendered_body = true;
|
rendered_body = true;
|
||||||
items.push(ModelSelectorItem::empty(
|
items.push(ModelSelectorItem::empty(
|
||||||
@@ -8141,7 +8252,7 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !scope_allowed {
|
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 {
|
if let Some(msg) = message {
|
||||||
rendered_body = true;
|
rendered_body = true;
|
||||||
items.push(ModelSelectorItem::empty(
|
items.push(ModelSelectorItem::empty(
|
||||||
@@ -8184,7 +8295,11 @@ impl ChatApp {
|
|||||||
match entry.state {
|
match entry.state {
|
||||||
ModelAvailabilityState::Unavailable => return ProviderStatus::Unavailable,
|
ModelAvailabilityState::Unavailable => return ProviderStatus::Unavailable,
|
||||||
ModelAvailabilityState::Unknown => saw_unknown = true,
|
ModelAvailabilityState::Unknown => saw_unknown = true,
|
||||||
ModelAvailabilityState::Available => {}
|
ModelAvailabilityState::Available => {
|
||||||
|
if entry.is_stale {
|
||||||
|
saw_unknown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if saw_unknown {
|
if saw_unknown {
|
||||||
@@ -8224,13 +8339,11 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filter_scope_allows_models(
|
fn filter_scope_allows_models(&self, scope: &ModelScope, status: &ScopeStatusEntry) -> bool {
|
||||||
&self,
|
|
||||||
scope: &ModelScope,
|
|
||||||
status: ModelAvailabilityState,
|
|
||||||
) -> bool {
|
|
||||||
match self.model_filter_mode {
|
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::LocalOnly => matches!(scope, ModelScope::Local),
|
||||||
FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud),
|
FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud),
|
||||||
FilterMode::All => true,
|
FilterMode::All => true,
|
||||||
@@ -8240,22 +8353,32 @@ impl ChatApp {
|
|||||||
fn scope_filter_message(
|
fn scope_filter_message(
|
||||||
&self,
|
&self,
|
||||||
scope: &ModelScope,
|
scope: &ModelScope,
|
||||||
status: ModelAvailabilityState,
|
status: &ScopeStatusEntry,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
match self.model_filter_mode {
|
match self.model_filter_mode {
|
||||||
FilterMode::Available => match status {
|
FilterMode::Available => {
|
||||||
ModelAvailabilityState::Available => None,
|
if status.state == ModelAvailabilityState::Available && !status.is_stale {
|
||||||
ModelAvailabilityState::Unavailable => {
|
return None;
|
||||||
Some(format!("{} unavailable", Self::scope_display_name(scope)))
|
|
||||||
}
|
}
|
||||||
ModelAvailabilityState::Unknown => Some(format!(
|
Self::scope_status_summary(status).or_else(|| match status.state {
|
||||||
"{} setup required",
|
ModelAvailabilityState::Unavailable => {
|
||||||
Self::scope_display_name(scope)
|
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 => {
|
FilterMode::LocalOnly | FilterMode::CloudOnly => {
|
||||||
if status == ModelAvailabilityState::Unavailable {
|
if status.state == ModelAvailabilityState::Unavailable {
|
||||||
Some(format!("{} unavailable", Self::scope_display_name(scope)))
|
Self::scope_status_summary(status).or_else(|| {
|
||||||
|
Some(format!("{} unavailable", Self::scope_display_name(scope)))
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -11245,7 +11368,7 @@ fn normalize_cloud_endpoint(endpoint: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 crate::app::UiRuntime;
|
||||||
use futures_util::{future, stream};
|
use futures_util::{future, stream};
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
@@ -11337,6 +11460,35 @@ mod tests {
|
|||||||
assert!(wrapped.is_empty());
|
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;
|
struct StubProvider;
|
||||||
|
|
||||||
impl LlmProvider for StubProvider {
|
impl LlmProvider for StubProvider {
|
||||||
|
|||||||
Reference in New Issue
Block a user