//! Model fetching, resolution, and management. use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use parking_lot::RwLock; use agent_client_protocol as acp; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use indexmap::IndexMap; use crate::agent::config::{self, ModelEntry, resolve_credentials, sampling_config_for_model}; use crate::auth::{AuthManager, GrokAuth, GrokComConfig}; use crate::remote::{FetchModelsResult, fetch_models_blocking}; use crate::sampling::SamplerConfig as SamplingConfig; use globset::{Glob, GlobSet, GlobSetBuilder}; use xai_grok_sampling_types::{ReasoningEffort, ReasoningEffortOption}; // ── Auth method for model fetching ────────────────────────────────────────── /// Credential for `/v1/models` fetching. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ModelFetchAuth { Session, ApiKey, Deployment, CustomEndpoint, } impl ModelFetchAuth { /// custom_endpoint > session > deployment > API key. pub(crate) fn resolve(endpoints: &config::EndpointsConfig, has_cached_session: bool) -> Self { if endpoints.has_custom_endpoint() { Self::CustomEndpoint } else if has_cached_session { Self::Session } else if endpoints.deployment_key.is_some() { Self::Deployment } else if crate::agent::auth_method::has_xai_api_key_env() { Self::ApiKey } else { Self::Session } } fn cache_auth_method(&self) -> CacheAuthMethod { match self { Self::CustomEndpoint | Self::ApiKey => CacheAuthMethod::ApiKey, Self::Session => CacheAuthMethod::Session, Self::Deployment => CacheAuthMethod::Deployment, } } } #[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq, Clone, Debug)] #[serde(rename_all = "snake_case")] pub(crate) enum CacheAuthMethod { Session, ApiKey, Deployment, } pub(crate) fn task_model_error_for_catalog( requested: &str, available: &IndexMap, is_session_auth: bool, ) -> Option { let is_available = |entry: &ModelEntry| { entry.info.user_selectable && entry.info.visible_for_auth(is_session_auth) }; if config::find_model_by_id(available, requested).is_some_and(&is_available) { return None; } let mut slugs = available .iter() .filter(|(_, entry)| is_available(entry)) .map(|(slug, _)| slug.as_str()) .collect::>(); slugs.sort_unstable(); let guidance = if slugs.is_empty() { "No valid model slugs are currently available. Omit `model` to inherit the parent model." .to_string() } else { format!( "Valid model slugs: {}. Omit `model` to inherit the parent model.", slugs.join(", ") ) }; Some(format!("Unknown Task.model slug '{requested}'. {guidance}")) } /// Thread-safe model manager. #[derive(Clone)] pub struct ModelsManager { inner: Arc, } /// Catalog fields written together under one lock, so readers never see a torn mix. #[derive(Default)] struct CatalogState { prefetched: Option>, models: IndexMap, etag: Option, /// Gates whether the apply path reselects the default (first real catalog) has_fetched_real_catalog: bool, /// `allowed_models` matched nothing; the prompt path blocks instead. allowlist_excludes_all: bool, } struct Inner { catalog: RwLock, current_model_id: RwLock, current_reasoning_effort: RwLock>, // ── Owned context for self-contained refresh ──────────────── auth_manager: Arc, cfg: RwLock, fetch_auth: RwLock, gateway: RwLock>, cache: ModelsCacheManager, endpoint: Arc, /// Guard to prevent overlapping retry loops. retry_in_flight: AtomicBool, /// Single-flight for the etag-triggered background refresh (`spawn_fetch`). refresh_in_flight: AtomicBool, /// Model-switch signal: a generation counter bumped when the current model id changes. model_switch_watch: tokio::sync::watch::Sender, /// Set once the user explicitly picks a model (`/model`); guards the /// first-catalog reselect from clobbering that choice. user_selected_model: AtomicBool, } /// Clears an in-flight flag on drop so a panicking task can't wedge future refreshes. struct RetryInFlightGuard(Arc); impl Drop for RetryInFlightGuard { fn drop(&mut self) { self.0.retry_in_flight.store(false, Ordering::Release); } } struct RefreshInFlightGuard(Arc); impl Drop for RefreshInFlightGuard { fn drop(&mut self) { self.0.refresh_in_flight.store(false, Ordering::Release); } } impl Default for ModelsManager { fn default() -> Self { let grok_home = crate::util::grok_home::grok_home(); let auth_manager = Arc::new(AuthManager::new(&grok_home, GrokComConfig::default())); Self::new( None, IndexMap::new(), acp::ModelId::new("default"), auth_manager, config::Config::default(), ) } } /// Builder for [`ModelsManager`]; transport and disk cache default to production (tests override them). pub(crate) struct ModelsManagerBuilder { prefetched: Option>, models: IndexMap, current_model_id: acp::ModelId, auth_manager: Arc, cfg: config::Config, endpoint: Arc, cache: ModelsCacheManager, } impl ModelsManagerBuilder { pub(crate) fn new( prefetched: Option>, models: IndexMap, current_model_id: acp::ModelId, auth_manager: Arc, cfg: config::Config, ) -> Self { Self { prefetched, models, current_model_id, auth_manager, cfg, endpoint: Arc::new(HttpModelsEndpoint), cache: ModelsCacheManager::new(), } } #[cfg(test)] pub(crate) fn endpoint(mut self, endpoint: Arc) -> Self { self.endpoint = endpoint; self } #[cfg(test)] pub(crate) fn cache(mut self, cache: ModelsCacheManager) -> Self { self.cache = cache; self } pub(crate) fn build(self) -> ModelsManager { let has_session = self.auth_manager.current_or_expired().is_some(); let fetch_auth = ModelFetchAuth::resolve(&self.cfg.endpoints, has_session); let current_reasoning_effort = self.cfg.models.default_reasoning_effort; ModelsManager { inner: Arc::new(Inner { catalog: RwLock::new(CatalogState { prefetched: self.prefetched, models: self.models, ..Default::default() }), current_model_id: RwLock::new(self.current_model_id), current_reasoning_effort: RwLock::new(current_reasoning_effort), auth_manager: self.auth_manager, cfg: RwLock::new(self.cfg), fetch_auth: RwLock::new(fetch_auth), gateway: RwLock::new(None), cache: self.cache, endpoint: self.endpoint, retry_in_flight: AtomicBool::new(false), refresh_in_flight: AtomicBool::new(false), model_switch_watch: tokio::sync::watch::channel(0u64).0, user_selected_model: AtomicBool::new(false), }), } } } impl ModelsManager { pub(crate) fn new( prefetched: Option>, models: IndexMap, current_model_id: acp::ModelId, auth_manager: Arc, cfg: config::Config, ) -> Self { ModelsManagerBuilder::new(prefetched, models, current_model_id, auth_manager, cfg).build() } /// Subscribe to model-switch events. Returns a `watch::Receiver` pub fn subscribe_model_switch(&self) -> tokio::sync::watch::Receiver { self.inner.model_switch_watch.subscribe() } /// Cheap snapshot of the current model-switch generation, for the laziness-check poll loop. pub fn model_switch_generation(&self) -> u64 { *self.inner.model_switch_watch.borrow() } /// Build from a resolved config. Falls back to bundled default if no models available. pub fn from_config( cfg: &config::Config, prefetched_models: Option>, auth_manager: Arc, ) -> Result { let has_session = auth_manager.current_or_expired().is_some(); let is_session_auth = auth_manager .current_or_expired() .is_some_and(|a| a.is_session_auth()); let fetch_auth = ModelFetchAuth::resolve(&cfg.endpoints, has_session); let prefetched_models = prefetched_models.or_else(|| { let cache = ModelsCacheManager::new(); cache .load_fresh( &fetch_auth.cache_auth_method(), &crate::remote::models_list_url(&cfg.endpoints, fetch_auth), ) .map(|c| c.models) }); let has_prefetched = prefetched_models.is_some(); let catalog = resolve_model_catalog(cfg, prefetched_models.clone()); if has_prefetched { validate_selectable(cfg, &catalog)?; } let (current_model_key, current_model, model_source) = resolve_default_model(cfg, &catalog, is_session_auth); tracing::info!( model_id = %current_model.model, source = %model_source, "default model resolved" ); let current_model_id = acp::ModelId::new(Arc::from(current_model_key)); let mgr = Self::new( prefetched_models, catalog, current_model_id, auth_manager, cfg.clone(), ); if has_prefetched { mgr.inner.catalog.write().has_fetched_real_catalog = true; } Ok(mgr) } pub(crate) fn set_gateway(&self, gateway: xai_acp_lib::AcpAgentGatewaySender) { *self.inner.gateway.write() = Some(gateway); } /// Swap config, rebuild catalog, and reselect the model. pub fn apply_config(&self, new_config: config::Config) { if let Err(e) = new_config.validate_model_filters() { tracing::error!(error = %e, "ignoring config reload: invalid model filters"); return; } let prefetched = self.inner.catalog.read().prefetched.clone(); let new_catalog = resolve_model_catalog(&new_config, prefetched); let has_real_catalog = self.inner.catalog.read().has_fetched_real_catalog; if has_real_catalog && let Err(e) = validate_selectable(&new_config, &new_catalog) { tracing::error!(error = %e, "ignoring config reload: allowed_models excludes all models"); return; } let (old_preferred, old_default_is_campaign) = { let cfg = self.inner.cfg.read(); ( cfg.models.default.clone(), cfg.models.default_is_campaign_driven, ) }; let new_preferred = new_config.models.default.clone(); let has_session = self.inner.auth_manager.current_or_expired().is_some(); *self.inner.fetch_auth.write() = ModelFetchAuth::resolve(&new_config.endpoints, has_session); *self.inner.cfg.write() = new_config.clone(); { let mut cat = self.inner.catalog.write(); if has_real_catalog { cat.allowlist_excludes_all = allowlist_matches_nothing(&new_config, &new_catalog); } cat.models = new_catalog; } let preferred_changed = new_preferred != old_preferred && new_preferred.is_some(); let mut campaign_defaults = std::collections::HashSet::new(); if new_config.models.default_is_campaign_driven && let Some(d) = &new_preferred { campaign_defaults.insert(d.clone()); } if old_default_is_campaign && let Some(d) = &old_preferred { campaign_defaults.insert(d.clone()); } let campaign_only_flip = is_campaign_only_flip(&old_preferred, &new_preferred, &campaign_defaults); let current_still_ok = { let cat = self.inner.catalog.read(); let models = &cat.models; let cur = self.inner.current_model_id.read(); models .get(cur.0.as_ref()) .is_some_and(|e| e.info.user_selectable) }; if preferred_changed && !(campaign_only_flip && current_still_ok) { self.reselect_default_model(&new_config); } else { self.reselect_current_model_if_missing(&new_config); } self.notify_models_updated(); } /// [`Self::apply_config`] plus an unconditional default re-resolve, for remote-settings arrival while no session exists. pub fn apply_config_reselecting_default(&self, new_config: config::Config) { self.apply_config(new_config.clone()); self.reselect_default_model(&new_config); self.notify_models_updated(); } // ── Accessors ─────────────────────────────────────────────────── pub fn models(&self) -> IndexMap { self.inner.catalog.read().models.clone() } pub fn endpoints(&self) -> config::EndpointsConfig { self.inner.cfg.read().endpoints.clone() } /// Does the current credential grant access to OAuth-only models? fn is_session_auth(&self) -> bool { self.inner .auth_manager .current_or_expired() .is_some_and(|a| a.is_session_auth()) } /// ACP-visible (non-hidden) projection of the catalog. pub fn available(&self) -> IndexMap { let snapshot = { let cat = self.inner.catalog.read(); let models = &cat.models; models.clone() }; let selectable: IndexMap<_, _> = snapshot .into_iter() .filter(|(_, e)| e.info.user_selectable) .collect(); available_models(&selectable, self.is_session_auth()) } pub(crate) fn task_model_error(&self, requested: &str) -> Option { let is_session_auth = self.is_session_auth(); let cat = self.inner.catalog.read(); let models = &cat.models; task_model_error_for_catalog(requested, models, is_session_auth) } pub fn current_model_id(&self) -> acp::ModelId { self.inner.current_model_id.read().clone() } pub fn set_current_model_id(&self, id: acp::ModelId) { self.inner .user_selected_model .store(true, Ordering::Relaxed); self.set_current_model_id_internal(id); } fn set_current_model_id_internal(&self, id: acp::ModelId) { let changed = { let mut cur = self.inner.current_model_id.write(); let changed = *cur != id; *cur = id; changed }; if changed { self.inner .model_switch_watch .send_modify(|generation| *generation += 1); } } /// Per-model Layer-3 LazinessDetector config for `model_id` (disabled default when absent). pub fn laziness_detector_for(&self, model_id: &str) -> config::LazinessDetectorPerModelConfig { self.inner .catalog .read() .models .get(model_id) .map(|e| e.info().laziness_detector.clone()) .unwrap_or_default() } /// Test-only catalog poke: inserts a `ModelEntry` keyed by `id`, #[cfg(test)] pub(crate) fn insert_test_entry(&self, id: impl Into, entry: ModelEntry) { self.inner.catalog.write().models.insert(id.into(), entry); } pub fn current_reasoning_effort(&self) -> Option { *self.inner.current_reasoning_effort.read() } pub fn set_current_reasoning_effort(&self, effort: Option) { *self.inner.current_reasoning_effort.write() = effort; } /// Whether the given model supports reasoning effort according to the catalog. pub fn model_supports_reasoning_effort(&self, model_id: &str) -> bool { self.inner .catalog .read() .models .get(model_id) .map(|e| e.info().supports_reasoning_effort) .unwrap_or(false) } pub fn model_default_reasoning_effort(&self, model_id: &str) -> Option { self.inner .catalog .read() .models .get(model_id) .and_then(|e| e.info().reasoning_effort) } /// The raw catalog `reasoning_efforts` list for `model_id` with no fallback, pub fn model_reasoning_efforts(&self, model_id: &str) -> Vec { self.inner .catalog .read() .models .get(model_id) .map(|e| e.info().reasoning_efforts.clone()) .unwrap_or_default() } pub fn model_supports_backend_search(&self, model_id: &str) -> bool { self.inner .catalog .read() .models .get(model_id) .map(|e| e.info().supports_backend_search) .unwrap_or(false) } pub fn model_compactions_remaining( &self, model_id: &str, ) -> Option { self.inner .catalog .read() .models .get(model_id) .and_then(|e| e.info().compactions_remaining) } pub fn model_compaction_at_tokens( &self, model_id: &str, ) -> Option { self.inner .catalog .read() .models .get(model_id) .and_then(|e| e.info().compaction_at_tokens) } /// Catalog opt-in to display the served-checkpoint fingerprint for this model. pub fn model_show_model_fingerprint(&self, model_id: &str) -> bool { let cat = self.inner.catalog.read(); let models = &cat.models; resolve_catalog_key(models, &acp::ModelId::new(model_id)) .and_then(|key| models.get(key.0.as_ref())) .map(|e| e.info().show_model_fingerprint) .unwrap_or(false) } /// Resolved next-prompt-suggestion model pin from the live config pub fn prompt_suggest_model_pin(&self) -> crate::config::PromptSuggestModelPin { self.inner.cfg.read().prompt_suggest_model_pin.clone() } /// Whether `model_id` resolves in the current catalog — as a config key pub fn model_in_catalog(&self, model_id: &str) -> bool { let cat = self.inner.catalog.read(); let models = &cat.models; resolve_catalog_key(models, &acp::ModelId::new(model_id)).is_some() } #[cfg(test)] fn prefetched(&self) -> Option> { self.inner.catalog.read().prefetched.clone() } #[cfg(test)] fn has_fetched_real_catalog(&self) -> bool { self.inner.catalog.read().has_fetched_real_catalog } // ── Mutations ─────────────────────────────────────────────────── fn rebuild(&self, cfg: &config::Config, prefetched: Option>) { self.inner.catalog.write().models = resolve_model_catalog(cfg, prefetched); } /// Refresh models when the etag changes. pub async fn refresh_if_new_etag(&self, etag: String) { let same_etag = { let cat = self.inner.catalog.read(); cat.etag.as_deref() == Some(etag.as_str()) }; if same_etag { let fetch_auth = *self.inner.fetch_auth.read(); self.inner .cache .renew_ttl(&fetch_auth.cache_auth_method(), &self.cache_origin()) .await; return; } tracing::info!(etag = %etag, "models etag changed, refreshing"); self.spawn_fetch(Some(etag)); } /// Auth identity changed: invalidate disk cache and refresh the catalog. pub async fn on_auth_changed(&self) { let config = self.inner.cfg.read().clone(); crate::agent::init::update_telemetry_config(&config, &self.inner.auth_manager); self.inner.cache.invalidate(); let has_session = self.inner.auth_manager.current_or_expired().is_some(); let fetch_auth = ModelFetchAuth::resolve(&config.endpoints, has_session); *self.inner.fetch_auth.write() = fetch_auth; if self.inner.auth_manager.current_or_expired().is_none() && fetch_auth == ModelFetchAuth::Session { self.clear(); return; } let remote_fetch_enabled = crate::util::config::resolve_remote_fetch_enabled(); self.fetch_and_apply_inner(remote_fetch_enabled).await; let needs_bundled_fallback = { let cat = self.inner.catalog.read(); !cat.has_fetched_real_catalog && cat.prefetched.is_none() }; if needs_bundled_fallback { if remote_fetch_enabled { xai_grok_telemetry::unified_log::warn( "model catalog: falling back to bundled defaults only", None, Some(serde_json::json!({ "trigger": "on_auth_changed", "had_real_catalog": false, })), ); } else { tracing::debug!("model catalog: bundled defaults in use (remote_fetch disabled)"); } self.rebuild(&config, None); self.reselect_current_model_if_missing(&config); if remote_fetch_enabled { self.spawn_catalog_retry(); } } self.notify_models_updated(); } fn notify_models_updated(&self) { let available = self.available(); let current = self.current_model_id(); let count = available.len(); xai_grok_telemetry::unified_log::info( "model catalog: notifying clients", None, Some(serde_json::json!({ "model_count": count, "current_model_id": current.0.as_ref(), })), ); if let Some(ref gw) = *self.inner.gateway.read() { let model_state = acp::SessionModelState::new(current, available.values().cloned().collect()); if let Ok(params) = serde_json::value::to_raw_value(&model_state) { gw.forward_fire_and_forget(acp::ExtNotification::new( "x.ai/models/update", params.into(), )); } } } /// Hot-reload the catalog from `~/.grok/models_cache.json` after an external write (config-watcher detected). pub fn reload_from_disk_cache(&self) { self.reload_from_cache_manager(&self.inner.cache); } /// Core of [`Self::reload_from_disk_cache`], parameterized over the cache fn reload_from_cache_manager(&self, cache: &ModelsCacheManager) { let fetch_auth = *self.inner.fetch_auth.read(); let Some(cached) = cache.load_fresh(&fetch_auth.cache_auth_method(), &self.cache_origin()) else { tracing::debug!("models cache changed on disk but is not loadable; ignoring"); return; }; let same_content = { let cat = self.inner.catalog.read(); cat.prefetched.as_ref().is_some_and(|current| { serde_json::to_string(current).ok() == serde_json::to_string(&cached.models).ok() }) }; if same_content { if cached.etag.is_some() { self.inner.catalog.write().etag = cached.etag; } tracing::debug!("models cache changed on disk but catalog is identical; skipping"); return; } let cfg = self.inner.cfg.read().clone(); let count = cached.models.len(); self.apply_catalog(&cfg, cached.models, cached.etag); tracing::info!(count, "model catalog hot-reloaded from disk cache"); xai_grok_telemetry::unified_log::info( "model catalog: reloaded from external disk-cache write", None, Some(serde_json::json!({ "model_count": count })), ); self.notify_models_updated(); } /// Retry model catalog fetch in the background with exponential backoff. fn spawn_catalog_retry(&self) { self.spawn_catalog_retry_with_backoff(crate::tools::retry::BackoffConfig::new( 5, 5_000, 60_000, )); } /// [`Self::spawn_catalog_retry`] with an injectable backoff (fast in tests). fn spawn_catalog_retry_with_backoff(&self, backoff: crate::tools::retry::BackoffConfig) { if !crate::util::config::resolve_remote_fetch_enabled() { return; } if self .inner .retry_in_flight .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { tracing::debug!("model catalog retry already in flight, skipping"); return; } let mgr = self.clone(); tokio::task::spawn(async move { let _retry_guard = RetryInFlightGuard(mgr.inner.clone()); let result = crate::tools::retry::execute_with_backoff( &backoff, || { let mgr = mgr.clone(); async move { if mgr.inner.catalog.read().has_fetched_real_catalog { return Ok(()); } mgr.fetch_and_apply().await; if mgr.inner.catalog.read().has_fetched_real_catalog { Ok(()) } else { Err("model catalog fetch returned no models") } } }, |attempt, max_retries, delay| async move { xai_grok_telemetry::unified_log::warn( "model catalog: retry scheduled", None, Some(serde_json::json!({ "attempt": attempt, "max_retries": max_retries, "delay_ms": delay.as_millis() as u64, })), ); }, ) .await; match result { Ok(()) => { let count = mgr.available().len(); xai_grok_telemetry::unified_log::info( "model catalog: retry succeeded", None, Some(serde_json::json!({ "model_count": count })), ); mgr.notify_models_updated(); } Err(e) => { xai_grok_telemetry::unified_log::warn( "model catalog: all retries exhausted", None, Some(serde_json::json!({ "error": e })), ); } } }); } /// One-shot background catalog refresh after readiness; no-op when a fresh disk cache already loaded a real catalog. pub fn spawn_background_refresh(&self) { if self.inner.catalog.read().has_fetched_real_catalog { tracing::debug!( "skipping startup background model refresh: fresh cache already loaded" ); return; } self.spawn_catalog_retry(); } /// Refresh the model catalog on every auth token refresh. pub fn start_auth_refresh_watcher(&self, notify: Arc) { let mgr = self.clone(); let had_catalog_at_start = self.inner.catalog.read().has_fetched_real_catalog; xai_grok_telemetry::unified_log::info( "model catalog: auth refresh watcher started", None, Some(serde_json::json!({ "had_real_catalog": had_catalog_at_start, "model_count": self.available().len(), })), ); tokio::spawn(async move { loop { notify.notified().await; if !crate::util::config::resolve_remote_fetch_enabled() { tracing::debug!( "model catalog: auth refresh watcher skipped (remote_fetch disabled)" ); continue; } let had_catalog = mgr.inner.catalog.read().has_fetched_real_catalog; let old_count = mgr.available().len(); xai_grok_telemetry::unified_log::info( "model catalog: auth refresh watcher triggered", None, Some(serde_json::json!({ "had_real_catalog": had_catalog, "model_count_before": old_count, })), ); mgr.fetch_and_apply().await; let has_catalog = mgr.inner.catalog.read().has_fetched_real_catalog; let new_count = mgr.available().len(); if has_catalog { if !had_catalog || new_count != old_count { xai_grok_telemetry::unified_log::info( "model catalog: auth refresh watcher updated catalog", None, Some(serde_json::json!({ "model_count_before": old_count, "model_count_after": new_count, "was_recovery": !had_catalog, })), ); } mgr.notify_models_updated(); } else { xai_grok_telemetry::unified_log::warn( "model catalog: auth refresh watcher fetch failed", None, Some(serde_json::json!({ "model_count": old_count, })), ); } } }); } /// Wipe in-memory state so a previous identity's catalog doesn't leak. fn clear(&self) { *self.inner.catalog.write() = CatalogState::default(); // A new identity starts fresh: drop the prior user's pick so its // first catalog reselects that identity's default. self.inner .user_selected_model .store(false, Ordering::Relaxed); } /// Build a `SamplingConfig` from the current model + auth state. pub fn sampling_config(&self) -> SamplingConfig { let config = self.inner.cfg.read().clone(); let auth_manager = self.inner.auth_manager.as_ref(); let current_model_id = self.current_model_id(); let all_models = self.models(); let fallback; let current_model = match all_models .get(current_model_id.0.as_ref()) .or_else(|| all_models.values().next()) { Some(m) => m, None => { tracing::warn!("no models available in catalog; defaulting to bundled model"); let default_id = crate::models::default_model().to_string(); fallback = ModelEntry::fallback(&default_id, &config.endpoints); &fallback } }; let session_auth = auth_manager.current_or_expired(); let credentials = resolve_credentials(current_model, session_auth.as_ref().map(|a| a.key.as_str())); sampling_config_for_model( current_model, credentials, config.endpoints.alpha_test_key.clone(), config.client_version.clone(), crate::managed_config::resolve_deployment_id( config.endpoints.deployment_key.as_deref(), ), None, ) } /// Disk-cache origin key for this manager's current endpoints/auth shape fn cache_origin(&self) -> String { let endpoints = self.inner.cfg.read().endpoints.clone(); let fetch_auth = *self.inner.fetch_auth.read(); crate::remote::models_list_url(&endpoints, fetch_auth) } fn try_load_cache(&self) -> bool { let fetch_auth = *self.inner.fetch_auth.read(); let Some(cached) = self .inner .cache .load_fresh(&fetch_auth.cache_auth_method(), &self.cache_origin()) else { return false; }; let cfg = self.inner.cfg.read().clone(); self.apply_catalog(&cfg, cached.models, cached.etag); true } /// A catalog-fetch session refresh bounded by `STARTUP_AUTH_REFRESH_TIMEOUT`. /// A hung IdP on a cold cache degrades to a session-less fetch (the /// bundled/cache catalog stays and the next refresh retries) instead of /// stalling boot, mirroring the readiness path's no-mint auth bound. async fn bounded_startup_auth(auth_manager: &Arc) -> Option { Self::bounded_auth_refresh(async { auth_manager.auth().await.ok() }).await } /// Bounds an auth-refresh future to `STARTUP_AUTH_REFRESH_TIMEOUT`, yielding /// `None` on timeout. Split out so the timeout contract is unit-testable /// without a live IdP. async fn bounded_auth_refresh(fut: F) -> Option where F: std::future::Future>, { match tokio::time::timeout(crate::http::STARTUP_AUTH_REFRESH_TIMEOUT, fut).await { Ok(auth) => auth, Err(_) => { tracing::warn!( timeout_secs = crate::http::STARTUP_AUTH_REFRESH_TIMEOUT.as_secs(), "model catalog: auth refresh timed out; fetching without a fresh session" ); None } } } fn spawn_fetch(&self, new_etag: Option) { self.spawn_fetch_inner( new_etag, crate::util::config::resolve_remote_fetch_enabled(), ); } /// `remote_fetch_enabled` is a parameter so tests can drive the gate without touching on-disk config. fn spawn_fetch_inner(&self, new_etag: Option, remote_fetch_enabled: bool) { if !remote_fetch_enabled { tracing::info!("model catalog refresh skipped: remote_fetch disabled"); return; } if self .inner .refresh_in_flight .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { tracing::debug!("model catalog refresh already in flight, skipping"); return; } let cfg = self.inner.cfg.read().clone(); let endpoints = cfg.endpoints.clone(); let fetch_auth = *self.inner.fetch_auth.read(); let auth_manager = self.inner.auth_manager.clone(); let endpoint = self.inner.endpoint.clone(); let mgr = self.clone(); tokio::task::spawn(async move { let _refresh_guard = RefreshInFlightGuard(mgr.inner.clone()); let auth = Self::bounded_startup_auth(&auth_manager).await; let new_prefetched = match tokio::time::timeout( crate::http::STARTUP_FETCH_TIMEOUT, endpoint.fetch_models(endpoints, auth, fetch_auth), ) .await { Ok(models) => models, Err(_) => { tracing::warn!("etag-triggered model refresh timed out"); None } }; if !mgr.apply_refresh_result(&cfg, new_prefetched, new_etag) { return; } tracing::info!("models manager refreshed"); mgr.notify_models_updated(); }); } /// Resolve the model list: tries cache first, then fetches from the network. pub async fn list_models(&self, strategy: RefreshStrategy) { match strategy { RefreshStrategy::Offline => { self.try_load_cache(); } RefreshStrategy::OnlineIfUncached => { if self.try_load_cache() { return; } self.fetch_and_apply().await; } RefreshStrategy::Online => { self.fetch_and_apply().await; } } } async fn fetch_and_apply(&self) { self.fetch_and_apply_inner(crate::util::config::resolve_remote_fetch_enabled()) .await } /// `remote_fetch_enabled` is a parameter so tests can drive the gate async fn fetch_and_apply_inner(&self, remote_fetch_enabled: bool) { if !remote_fetch_enabled { tracing::info!("model catalog refresh skipped: remote_fetch disabled"); return; } let auth = Self::bounded_startup_auth(&self.inner.auth_manager).await; let has_auth = auth.is_some(); let fetch_auth = *self.inner.fetch_auth.read(); let cfg = self.inner.cfg.read().clone(); xai_grok_telemetry::unified_log::info( "model catalog: fetching", None, Some(serde_json::json!({ "has_auth": has_auth, "fetch_auth": format!("{fetch_auth:?}"), })), ); let endpoint = self.inner.endpoint.clone(); let new_prefetched = match tokio::time::timeout( crate::http::STARTUP_FETCH_TIMEOUT, endpoint.fetch_models(cfg.endpoints.clone(), auth, fetch_auth), ) .await { Ok(res) => res, Err(_elapsed) => { tracing::warn!( timeout_secs = crate::http::STARTUP_FETCH_TIMEOUT.as_secs(), "model catalog fetch timed out" ); None } }; let success = self.apply_refresh_result(&cfg, new_prefetched, None); if success { xai_grok_telemetry::unified_log::info( "model catalog: fetch succeeded", None, Some(serde_json::json!({ "model_count": self.available().len(), })), ); } } /// Publish a resolved catalog under one atomic write, then reselect the model (default on first real catalog, else keep current if present). fn apply_catalog( &self, cfg: &config::Config, models: IndexMap, new_etag: Option, ) { let (first_real_catalog, excludes_all) = { let mut cat = self.inner.catalog.write(); let first_real_catalog = !cat.has_fetched_real_catalog; cat.has_fetched_real_catalog = true; cat.prefetched = Some(models); cat.models = resolve_model_catalog(cfg, cat.prefetched.clone()); cat.etag = new_etag; cat.allowlist_excludes_all = allowlist_matches_nothing(cfg, &cat.models); (first_real_catalog, cat.allowlist_excludes_all) }; if excludes_all { tracing::error!("allowed_models excludes all fetched models; prompts will be blocked"); } // Respect an explicit pre-catalog `/model` pick: auto-select the // default on the first catalog only when the user hasn't chosen. // Either way a now-invalid selection is replaced. if first_real_catalog && !self.inner.user_selected_model.load(Ordering::Relaxed) { self.reselect_default_model(cfg); } else { self.reselect_current_model_if_missing(cfg); } } fn apply_refresh_result( &self, config: &config::Config, new_prefetched: Option>, new_etag: Option, ) -> bool { let Some(new_prefetched) = new_prefetched else { tracing::warn!("model refresh failed, leaving existing models unchanged"); xai_grok_telemetry::unified_log::warn( "model catalog refresh failed", None, Some(serde_json::json!({ "had_real_catalog": self.inner.catalog.read().has_fetched_real_catalog, })), ); return false; }; self.apply_catalog(config, new_prefetched, new_etag); true } pub fn allowlist_excludes_all(&self) -> bool { self.inner.catalog.read().allowlist_excludes_all } /// Re-pick the default if `current_model_id` is gone from the catalog *or* fn reselect_current_model_if_missing(&self, config: &config::Config) { let current = self.inner.current_model_id.read().clone(); let needs_reselection = { let cat = self.inner.catalog.read(); let models = &cat.models; match models.get(current.0.as_ref()) { None => true, Some(entry) => !entry.info.user_selectable, } }; if !needs_reselection { return; } let (key, _, source) = { let cat = self.inner.catalog.read(); let models = &cat.models; resolve_default_model(config, models, self.is_session_auth()) }; let new_id = acp::ModelId::new(Arc::from(key)); tracing::info!( old = %current.0, new = %new_id.0, source = %source, "current model not in new catalog, reselecting default" ); self.set_current_model_id_internal(new_id); } /// Re-resolve the default model against the current catalog. fn reselect_default_model(&self, config: &config::Config) { let (key, _, source) = { let cat = self.inner.catalog.read(); let models = &cat.models; resolve_default_model(config, models, self.is_session_auth()) }; let new_id = acp::ModelId::new(Arc::from(key)); let current = self.inner.current_model_id.read().clone(); if current.0.as_ref() != new_id.0.as_ref() { tracing::info!( old = %current.0, new = %new_id.0, source = %source, "re-resolved default model after catalog populated" ); self.set_current_model_id_internal(new_id); } } } // ── Refresh strategy ──────────────────────────────────────────────────────── /// How to resolve the model list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefreshStrategy { /// Always fetch from network, ignore cache. Online, /// Only use cached data, never fetch. Offline, /// Use cache if fresh, otherwise fetch. OnlineIfUncached, } mod cache; mod endpoint; mod fetch; mod resolution; pub(crate) use cache::*; pub(crate) use endpoint::*; pub(crate) use fetch::*; pub use fetch::{ EarlyPrefetchHandle, EarlyPrefetchResult, start_early_prefetch, start_early_prefetch_settings_only, start_early_prefetch_with_auth, }; pub(crate) use resolution::*; #[cfg(test)] mod tests;