//! Model fetching, resolution, and management. 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. /// /// A `deployment_key` outranks an ambient `XAI_API_KEY` so a stray env key /// can't redirect model fetching from the deployment's entitlement-gated /// proxy to a raw `/v1/models` endpoint that lists the full model registry. 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")] 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. /// /// Owns the auth manager, config, and gateway needed to refresh models. /// Uses `parking_lot::RwLock` for short clone-and-release access. #[derive(Clone)] pub struct ModelsManager { inner: Arc, } struct Inner { prefetched: RwLock>>, models: RwLock>, current_model_id: RwLock, current_reasoning_effort: RwLock>, etag: RwLock>, /// Set once a real catalog has been fetched; gates whether /// `apply_refresh_result` calls `reselect_default_model` (first /// time) or `reselect_current_model_if_missing` (subsequent). /// Reset in `clear()` for identity changes. has_fetched_real_catalog: RwLock, // ── Owned context for self-contained refresh ──────────────── auth_manager: Arc, cfg: RwLock, fetch_auth: RwLock, gateway: RwLock>, cache: ModelsCacheManager, /// Guard to prevent overlapping retry loops. retry_in_flight: AtomicBool, /// `allowed_models` matched nothing in the fetched catalog; the prompt path /// blocks rather than run on the bundled default. Set in `apply_refresh_result`. allowlist_excludes_all: AtomicBool, /// Layer-3 LazinessDetector model-switch signal. Carries a /// monotonically-increasing generation counter (`u64`) that is /// bumped whenever the current model id actually changes via /// [`Self::set_current_model_id`]. /// /// Two consumer patterns: /// 1. `subscribe_model_switch().changed().await` — used by the /// `SessionActor` main loop to react to a switch (e.g. zero /// the per-session nudge counter). Critically, `watch::Receiver` /// only resolves `.changed()` on changes that happen **after** /// subscription — there is no stored-permit hazard akin to /// `tokio::sync::Notify::notify_one()`. /// 2. `model_switch_generation()` — cheap snapshot read used by /// `maybe_fire_laziness_check`'s polling loop to detect a /// switch that occurred during the idle wait or sampler call. /// /// `watch::Sender` natively fans out to every subscriber, so this /// replaces the previous `RwLock>>` listener /// registry — no manual fan-out, no listener-leak risk, no /// `unregister` API to maintain. model_switch_watch: tokio::sync::watch::Sender, } 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(), ) } } impl ModelsManager { pub(crate) fn new( prefetched: Option>, models: IndexMap, current_model_id: acp::ModelId, auth_manager: Arc, cfg: config::Config, ) -> Self { let has_session = auth_manager.current_or_expired().is_some(); let fetch_auth = ModelFetchAuth::resolve(&cfg.endpoints, has_session); let current_reasoning_effort = cfg.models.default_reasoning_effort; Self { inner: Arc::new(Inner { prefetched: RwLock::new(prefetched), models: RwLock::new(models), current_model_id: RwLock::new(current_model_id), current_reasoning_effort: RwLock::new(current_reasoning_effort), etag: RwLock::new(None), has_fetched_real_catalog: RwLock::new(false), auth_manager, cfg: RwLock::new(cfg), fetch_auth: RwLock::new(fetch_auth), gateway: RwLock::new(None), cache: ModelsCacheManager::new(), retry_in_flight: AtomicBool::new(false), allowlist_excludes_all: AtomicBool::new(false), model_switch_watch: tokio::sync::watch::channel(0u64).0, }), } } /// Subscribe to model-switch events. Returns a `watch::Receiver` /// carrying the monotonic generation counter. `.changed()` only /// resolves on switches that occur **after** subscription, so /// there is no stored-permit hazard (the bug that motivated /// replacing the previous `Arc` design). pub fn subscribe_model_switch(&self) -> tokio::sync::watch::Receiver { self.inner.model_switch_watch.subscribe() } /// Cheap snapshot of the current model-switch generation. Used by /// `maybe_fire_laziness_check`'s polling loop to detect a switch /// that occurred during the idle wait or sampler call without /// having to allocate a fresh `Receiver` per fire. 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. /// /// When `prefetched_models` is `None`, the disk cache is consulted so that /// server-side models are available for default-model resolution even when /// the caller didn't do an explicit prefetch. 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()); // Validate only against a real catalog; a bundled-only first run defers // to the async fetch (`apply_refresh_result`). 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.has_fetched_real_catalog.write() = 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. /// /// Calls `reselect_default_model` when the preferred model changed /// (and is `Some`); otherwise `reselect_current_model_if_missing`. pub fn apply_config(&self, new_config: config::Config) { // Reject an invalid reload instead of mutating live state: bad globs or // (once a real catalog exists) an allowlist that excludes everything. if let Err(e) = new_config.validate_model_filters() { tracing::error!(error = %e, "ignoring config reload: invalid model filters"); return; } let prefetched = self.inner.prefetched.read().clone(); let new_catalog = resolve_model_catalog(&new_config, prefetched); let has_real_catalog = *self.inner.has_fetched_real_catalog.read(); 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(); // Recompute the prompt-block flag so a corrective reload unblocks. if has_real_catalog { let excludes_all = allowlist_matches_nothing(&new_config, &new_catalog); self.inner .allowlist_excludes_all .store(excludes_all, Ordering::Relaxed); } *self.inner.models.write() = new_catalog; // A preferred-model flip caused only by a campaign overlay appearing or // disappearing must not yank an in-flight session whose current model is // still usable — the campaign applies to /new sessions only. let preferred_changed = new_preferred != old_preferred && new_preferred.is_some(); // Recognize an appearing OR withdrawing campaign from the // `default_is_campaign_driven` flag on each config (no disk I/O); correct // even when the user has no base default (where a value compare would miss). 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 models = self.inner.models.read(); 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); } // Push the new catalog to connected clients (`x.ai/models/update`). // Without this, a long-running agent (leader mode) correctly swaps // its in-memory catalog on a config.toml `[model.*]`/`[models]` edit, // but already-connected clients keep rendering the stale model list // until they reconnect. No-op when no gateway is attached (tests, // pre-init). self.notify_models_updated(); } // ── Accessors ─────────────────────────────────────────────────── pub fn models(&self) -> IndexMap { self.inner.models.read().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. /// The catalog coming from `resolve_model_catalog` already has /// allowed_models + disabled_models + hidden_models applied. pub fn available(&self) -> IndexMap { let snapshot = { let models = self.inner.models.read(); 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 models = self.inner.models.read(); 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) { // Only bump the model-switch generation on a real change. // The pager's `/model` handler can call this with the // already-active id during re-resolution; bumping the counter // in that case would needlessly cancel a healthy in-flight // classifier call and zero the per-session nudge counter. 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); } } /// Look up the per-model Layer-3 LazinessDetector config for the /// model identified by `model_id`. Returns the default (disabled) /// config when the id isn't in the catalog — same fallback /// semantics as the `auto_compact_threshold_percent` lookup. pub fn laziness_detector_for(&self, model_id: &str) -> config::LazinessDetectorPerModelConfig { self.inner .models .read() .get(model_id) .map(|e| e.info().laziness_detector.clone()) .unwrap_or_default() } /// Test-only catalog poke: inserts a `ModelEntry` keyed by `id`, /// allowing integration tests to enable Layer-3 features per /// model without spinning up the full config-merge pipeline. #[cfg(test)] pub(crate) fn insert_test_entry(&self, id: impl Into, entry: ModelEntry) { self.inner.models.write().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 .models .read() .get(model_id) .map(|e| e.info().supports_reasoning_effort) .unwrap_or(false) } /// The catalog default reasoning effort for `model_id`, if the catalog /// pins one. Used as the final fallback when neither the session handle /// nor the global config sets an explicit effort, so surfaced config stays /// consistent with the effort sampling actually uses. pub fn model_default_reasoning_effort(&self, model_id: &str) -> Option { self.inner .models .read() .get(model_id) .and_then(|e| e.info().reasoning_effort) } /// The raw catalog `reasoning_efforts` list for `model_id` with no fallback, /// empty when the catalog pins none (caller falls back to the built-in /// session modes). Distinct from the pager's gate-first, fallback-applied /// `ModelState::reasoning_effort_options`. pub fn model_reasoning_efforts(&self, model_id: &str) -> Vec { self.inner .models .read() .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 .models .read() .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 .models .read() .get(model_id) .and_then(|e| e.info().compactions_remaining) } pub fn model_compaction_at_tokens( &self, model_id: &str, ) -> Option { self.inner .models .read() .get(model_id) .and_then(|e| e.info().compaction_at_tokens) } /// Catalog opt-in to display the served-checkpoint fingerprint for this model. /// /// `model_id` may be a routing slug (`config.model`, e.g. `grok-4.5`) /// OR a catalog key; the catalog map is keyed by the config key, which can /// differ from the slug for custom/enterprise ids (e.g. key `enterprise-grok-build` /// → slug `grok-4.5`). Resolve to the catalog key first so a slug /// caller still finds the opted-in entry. pub fn model_show_model_fingerprint(&self, model_id: &str) -> bool { let models = self.inner.models.read(); 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 /// (`env > [models] prompt_suggestion > remote settings`); tracks config /// hot-reloads via [`Self::apply_config`]. Consumed catalog-guarded by /// `handle_suggest_prompt`. 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 /// or a routing slug (see [`resolve_catalog_key`]). Deliberately checks /// the full catalog rather than the user-selectable projection: auxiliary /// background calls need a *sampleable* model, and hidden or /// non-selectable entries are still sampleable. pub fn model_in_catalog(&self, model_id: &str) -> bool { let models = self.inner.models.read(); resolve_catalog_key(&models, &acp::ModelId::new(model_id)).is_some() } #[cfg(test)] fn prefetched(&self) -> Option> { self.inner.prefetched.read().clone() } #[cfg(test)] fn has_fetched_real_catalog(&self) -> bool { *self.inner.has_fetched_real_catalog.read() } // ── Mutations ─────────────────────────────────────────────────── fn rebuild(&self, cfg: &config::Config, prefetched: Option>) { *self.inner.models.write() = resolve_model_catalog(cfg, prefetched); } /// Refresh models when the etag changes. /// /// Writes etag optimistically before spawning the fetch to coalesce /// concurrent callers seeing the same new etag. pub async fn refresh_if_new_etag(&self, etag: String) { let same_etag = { let current = self.inner.etag.read(); current.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; } *self.inner.etag.write() = Some(etag.clone()); tracing::info!(etag = %etag, "models etag changed, refreshing"); self.do_refresh(Some(etag), RefreshStrategy::Online); } /// Auth identity changed: invalidate disk cache and refresh the catalog. /// /// Safe on OIDC token recovery after idle: we never drop a successfully-fetched /// catalog on transient failure. Only fall back to the bundled default when /// we have never had a real catalog (`!has_fetched_real_catalog`), or via /// the genuine no-auth path (`clear()`). /// /// Respects the auth snapshot / hot-swap discipline. 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; } // Never eagerly drop prefetched on auth recovery. Only fall back to // bundled defaults when we have never had a real catalog. Resolved once // so the fetch and the failure-vs-disabled classification below agree. let remote_fetch_enabled = crate::util::config::resolve_remote_fetch_enabled(); self.fetch_and_apply_inner(remote_fetch_enabled).await; if !*self.inner.has_fetched_real_catalog.read() && self.inner.prefetched.read().is_none() { 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 { // Deliberate no-fetch state, not a failure: no warn-class log. tracing::debug!("model catalog: bundled defaults in use (remote_fetch disabled)"); } self.rebuild(&config, None); // first-time only: no fetched catalog, use bundled defaults self.reselect_current_model_if_missing(&config); // Schedule background retries so we recover once the network is // back (e.g. after sleep/resume when the first fetch races DNS). // With remote_fetch disabled a retry can never succeed, so none is // scheduled. if remote_fetch_enabled { self.spawn_catalog_retry(); } } self.notify_models_updated(); } /// Notify clients about the current model catalog. 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 (detected by the config file watcher). /// /// A long-running leader otherwise only refreshes its catalog from its /// *own* fetch paths (startup prefetch, auth change, response-header etag). /// When another grok process sharing `~/.grok` (a `--no-leader` run, a /// newer client, grok-desktop) fetches a fresher `/v1/models` catalog and /// persists it, this picks it up without a network round-trip. /// /// Guards, in order: /// 1. `load_fresh` — rejects stale (TTL), version-mismatched, /// auth-method-mismatched, or origin-mismatched cache files (another /// process running with different credentials or pointed at a /// different backend must not poison this catalog). /// 2. Content dedup — the leader itself rewrites the cache file /// (`persist` after fetch, `renew_ttl` on same-etag responses), and the /// watcher has no self-write suppression. If the cached models match /// the in-memory prefetched catalog this is a no-op (the etag is still /// adopted so `refresh_if_new_etag` doesn't refetch needlessly). /// /// On a real change: swaps the prefetched catalog, rebuilds, re-resolves /// the configured default when this is the first real catalog (otherwise /// reselects the current model if it disappeared), and notifies clients. 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 /// manager so tests can point it at a temp file (the production /// `ModelsCacheManager` path is fixed to `grok_home()`, a process-wide /// `OnceLock`). 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; }; // Self-write / no-change dedup by content. `ModelEntry` doesn't impl // `PartialEq` (nested config types), so compare the serialized form — // catalogs are small (tens of entries) and writes are debounced. let same_content = { let prefetched = self.inner.prefetched.read(); prefetched.as_ref().is_some_and(|current| { serde_json::to_string(current).ok() == serde_json::to_string(&cached.models).ok() }) }; if same_content { // Adopt the (possibly newer) etag without a rebuild so the next // response-header comparison in `refresh_if_new_etag` is accurate. if cached.etag.is_some() { *self.inner.etag.write() = 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(); // Capture whether this is the first real catalog (mirrors // `apply_refresh_result`): if the leader bootstrapped on bundled // defaults, the configured default must be re-resolved against the // real catalog rather than left on a placeholder. let first_real_catalog = { let mut flag = self.inner.has_fetched_real_catalog.write(); let was_first = !*flag; *flag = true; was_first }; *self.inner.prefetched.write() = Some(cached.models.clone()); self.rebuild(&cfg, Some(cached.models)); *self.inner.etag.write() = cached.etag; if first_real_catalog { self.reselect_default_model(&cfg); } else { self.reselect_current_model_if_missing(&cfg); } // Recompute the prompt-block flag (mirrors `apply_refresh_result`) so // a corrective external cache write unlatches a previously latched // "allowlist excludes everything" state instead of keeping prompts // blocked against a stale catalog. let excludes_all = allowlist_matches_nothing(&cfg, &self.inner.models.read()); self.inner .allowlist_excludes_all .store(excludes_all, Ordering::Relaxed); if excludes_all { tracing::error!("allowed_models excludes all fetched models; prompts will be blocked"); } 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. /// /// Spawned when `on_auth_changed` falls back to bundled defaults. Uses the /// crate-standard `execute_with_backoff` (5 attempts, 5s base, 60s cap) and /// notifies clients on success so the UI recovers after sleep/resume without /// requiring a manual restart. fn spawn_catalog_retry(&self) { // Deliberate no-fetch state: a retry loop can never succeed, so don't // start one (defensive re-check; the spawn site already gates). if !crate::util::config::resolve_remote_fetch_enabled() { return; } // Prevent overlapping retry loops. 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 backoff = crate::tools::retry::BackoffConfig::new(5, 5_000, 60_000); let result = crate::tools::retry::execute_with_backoff( &backoff, || { let mgr = mgr.clone(); async move { // Bail out early if another code path already loaded a real catalog. if *mgr.inner.has_fetched_real_catalog.read() { return Ok(()); } mgr.fetch_and_apply().await; if *mgr.inner.has_fetched_real_catalog.read() { 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 })), ); } } mgr.inner.retry_in_flight.store(false, Ordering::Release); }); } /// Refresh the model catalog on every auth token refresh. /// /// Listens for [`AuthManager::refresh_notifier`] signals directly, /// bypassing the FSEvents file watcher which can silently stop /// delivering events on macOS after resume from sleep. On each /// notification the catalog is re-fetched from the server; if the /// fetch succeeds and the catalog changed, clients are notified /// via `x.ai/models/update`. pub fn start_auth_refresh_watcher(&self, notify: Arc) { let mgr = self.clone(); let had_catalog_at_start = *self.inner.has_fetched_real_catalog.read(); 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; // Deliberate no-fetch state: skip the refresh entirely so the // failure-classifying logs below keep meaning "actually failed". 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.has_fetched_real_catalog.read(); 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.has_fetched_real_catalog.read(); 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.prefetched.write() = None; *self.inner.models.write() = IndexMap::new(); *self.inner.etag.write() = None; *self.inner.has_fetched_real_catalog.write() = false; self.inner .allowlist_excludes_all .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 /// (see [`ModelsCache::origin`]). 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.inner.has_fetched_real_catalog.write() = true; *self.inner.prefetched.write() = Some(cached.models.clone()); self.rebuild(&cfg, Some(cached.models)); *self.inner.etag.write() = cached.etag; true } fn spawn_fetch(&self, new_etag: Option) { // Degrade to Offline: keep serving the current (cache/static) catalog. if !crate::util::config::resolve_remote_fetch_enabled() { tracing::info!("model catalog refresh skipped: remote_fetch disabled"); 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 mgr = self.clone(); tokio::task::spawn(async move { let auth = auth_manager.auth().await.ok(); let new_prefetched = fetch_models_async(endpoints, auth, fetch_auth).await; if !mgr.apply_refresh_result(&cfg, new_prefetched, new_etag) { return; } tracing::info!("models manager refreshed"); mgr.notify_models_updated(); }); } /// Fetch models, rebuild state, and notify clients. fn do_refresh(&self, new_etag: Option, strategy: RefreshStrategy) { match strategy { RefreshStrategy::Offline => { if self.try_load_cache() { tracing::info!("models manager refreshed from cache (offline)"); } } RefreshStrategy::OnlineIfUncached => { if self.try_load_cache() { tracing::info!("models manager refreshed from cache (online_if_uncached)"); return; } self.spawn_fetch(new_etag); } RefreshStrategy::Online => { self.spawn_fetch(new_etag); } } } /// 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 /// without touching on-disk config layers. async fn fetch_and_apply_inner(&self, remote_fetch_enabled: bool) { // Degrade to Offline: keep serving the current (cache/static) catalog. if !remote_fetch_enabled { tracing::info!("model catalog refresh skipped: remote_fetch disabled"); return; } let auth = self.inner.auth_manager.auth().await.ok(); 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 new_prefetched = fetch_models_async(cfg.endpoints.clone(), auth, fetch_auth).await; 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(), })), ); } } 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.has_fetched_real_catalog.read(), })), ); return false; }; let first_real_catalog = { let mut flag = self.inner.has_fetched_real_catalog.write(); let was_first = !*flag; *flag = true; was_first }; *self.inner.prefetched.write() = Some(new_prefetched.clone()); self.rebuild(config, Some(new_prefetched)); *self.inner.etag.write() = new_etag; // Can't exit a running app; flag it so the prompt path blocks instead. let excludes_all = allowlist_matches_nothing(config, &self.inner.models.read()); self.inner .allowlist_excludes_all .store(excludes_all, Ordering::Relaxed); if excludes_all { tracing::error!("allowed_models excludes all fetched models; prompts will be blocked"); } if first_real_catalog { self.reselect_default_model(config); } else { self.reselect_current_model_if_missing(config); } true } pub fn allowlist_excludes_all(&self) -> bool { self.inner.allowlist_excludes_all.load(Ordering::Relaxed) } /// Re-pick the default if `current_model_id` is gone from the catalog *or* /// is no longer `user_selectable` (e.g. a config reload narrowed /// `allowed_models`), so UI and sampling don't disagree on the active model. fn reselect_current_model_if_missing(&self, config: &config::Config) { let current = self.inner.current_model_id.read().clone(); let needs_reselection = { let models = self.inner.models.read(); match models.get(current.0.as_ref()) { None => true, Some(entry) => !entry.info.user_selectable, } }; if !needs_reselection { return; } let (key, _, source) = { let models = self.inner.models.read(); 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.inner.current_model_id.write() = new_id; } /// Re-resolve the default model against the current catalog. /// /// Called on first catalog fetch and when `apply_config` detects a /// preferred-model change. fn reselect_default_model(&self, config: &config::Config) { let (key, _, source) = { let models = self.inner.models.read(); 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.inner.current_model_id.write() = 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, } // ── Disk cache ────────────────────────────────────────────────────────────── const MODELS_CACHE_FILE: &str = "models_cache.json"; const CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300); #[derive(serde::Serialize, serde::Deserialize)] struct ModelsCache { fetched_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] grok_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] auth_method: Option, /// Models-list URL this catalog was fetched from /// ([`crate::remote::models_list_url`]). Compared on load so a cache /// written against one backend is a miss for another: entries embed /// absolute `base_url`s, so adopting a foreign-origin cache silently /// re-points inference (the windows lifecycle e2e failed exactly this /// way — test 1's mock-server catalog, cached in the shared profile, /// sent test 2's prompts to a dead port). `None` (legacy files) never /// matches. #[serde(default, skip_serializing_if = "Option::is_none")] origin: Option, #[serde(default, skip_serializing_if = "Option::is_none")] etag: Option, models: IndexMap, } impl ModelsCache { fn is_fresh(&self, ttl: std::time::Duration) -> bool { let Ok(ttl) = ChronoDuration::from_std(ttl) else { return false; }; let age = Utc::now().signed_duration_since(self.fetched_at); age >= ChronoDuration::zero() && age < ttl } } struct CacheResult { models: IndexMap, etag: Option, } struct ModelsCacheManager { path: std::path::PathBuf, ttl: std::time::Duration, } impl ModelsCacheManager { fn new() -> Self { Self { path: crate::util::grok_home::grok_home().join(MODELS_CACHE_FILE), ttl: CACHE_TTL, } } /// Sync; used by `prefetch_models_blocking`. Will be removed once startup /// prefetch is async. fn load_fresh( &self, expected_auth: &CacheAuthMethod, expected_origin: &str, ) -> Option { let data = std::fs::read(&self.path).ok()?; let cache: ModelsCache = serde_json::from_slice(&data).ok()?; if cache.grok_version.as_deref() != Some(xai_grok_version::VERSION) { tracing::debug!("models cache version mismatch"); return None; } if cache.auth_method.as_ref() != Some(expected_auth) { tracing::debug!("models cache auth method mismatch"); return None; } if cache.origin.as_deref() != Some(expected_origin) { tracing::debug!( cached = ?cache.origin, expected = expected_origin, "models cache origin mismatch" ); return None; } if !cache.is_fresh(self.ttl) { tracing::debug!("models cache is stale"); return None; } tracing::debug!(count = cache.models.len(), "loaded models from disk cache"); Some(CacheResult { models: cache.models, etag: cache.etag, }) } /// Sync; see `load_fresh` note. fn persist( &self, models: &IndexMap, etag: Option<&str>, auth_method: CacheAuthMethod, origin: &str, ) { let cache = ModelsCache { fetched_at: Utc::now(), grok_version: Some(xai_grok_version::VERSION.to_string()), auth_method: Some(auth_method), origin: Some(origin.to_string()), etag: etag.map(|s| s.to_string()), models: models.clone(), }; self.atomic_write(&cache); } async fn renew_ttl(&self, expected_auth: &CacheAuthMethod, expected_origin: &str) { let data = match tokio::fs::read(&self.path).await { Ok(data) => data, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, Err(e) => { tracing::warn!(error = %e, "models cache TTL renewal: read failed"); return; } }; let Ok(mut cache) = serde_json::from_slice::(&data) else { return; }; if cache.auth_method.as_ref() != Some(expected_auth) { tracing::debug!("models cache TTL renewal skipped: auth method mismatch"); return; } if cache.origin.as_deref() != Some(expected_origin) { tracing::debug!("models cache TTL renewal skipped: origin mismatch"); return; } cache.fetched_at = Utc::now(); self.atomic_write_async(&cache).await; tracing::debug!("models cache TTL renewed"); } /// Sync; see `load_fresh` note. fn invalidate(&self) { match std::fs::remove_file(&self.path) { Ok(()) => tracing::info!("models disk cache invalidated"), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => tracing::warn!(error = %e, "failed to invalidate models disk cache"), } } /// Sync; see `load_fresh` note. fn atomic_write(&self, cache: &ModelsCache) { if let Some(parent) = self.path.parent() { let _ = std::fs::create_dir_all(parent); } let tmp = self.path.with_extension("json.tmp"); if let Ok(json) = serde_json::to_vec_pretty(cache) && std::fs::write(&tmp, &json).is_ok() { let _ = std::fs::rename(&tmp, &self.path); } } async fn atomic_write_async(&self, cache: &ModelsCache) { if let Some(parent) = self.path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } let tmp = self.path.with_extension("json.tmp"); let Ok(json) = serde_json::to_vec_pretty(cache) else { return; }; if tokio::fs::write(&tmp, &json).await.is_ok() { let _ = tokio::fs::rename(&tmp, &self.path).await; } } } // ── Fetch ─────────────────────────────────────────────────────────────────── /// Build the prefetched model map from a flat list of entries. /// /// Each entry is keyed by its `id` field (falling back to the `model` slug /// when `id` is absent). This lets A/B experiments that share the same /// routing slug (e.g. "Auto" and "Grok Build" both route to `grok-build`) /// coexist in the catalog without collision. fn build_prefetched_map( models: Vec, api_base_url_override: Option, ) -> IndexMap { let mut map: IndexMap = IndexMap::with_capacity(models.len()); for m in models { let key = m.id.clone().unwrap_or_else(|| m.model.clone()); let info = config::ModelInfo::from_config(&m); let entry = ModelEntry { info, api_key: None, env_key: None, auth_provider: None, api_base_url: m.api_base_url.clone().or(api_base_url_override.clone()), }; map.insert(key, entry); } map } /// Fetch remote models. Checks disk cache first; persists after fetch. pub(crate) fn prefetch_models_blocking( endpoints: &config::EndpointsConfig, auth: Option<&GrokAuth>, fetch_auth: ModelFetchAuth, ) -> Option> { prefetch_models_blocking_gated( endpoints, auth, fetch_auth, crate::util::config::resolve_remote_fetch_enabled(), ) } /// Blocking models + `/v1/settings` prefetch pair, shared by the early /// prefetch thread and the leader's startup phase so the settings gate lives /// once. The remote_fetch knob is resolved a single time so the two fetch /// decisions cannot disagree mid-startup. pub(crate) fn prefetch_models_and_settings_blocking( endpoints: &config::EndpointsConfig, auth: Option<&GrokAuth>, fetch_auth: ModelFetchAuth, ) -> ( Option>, Option, ) { let remote_fetch_enabled = crate::util::config::resolve_remote_fetch_enabled(); let models = prefetch_models_blocking_gated(endpoints, auth, fetch_auth, remote_fetch_enabled); // Settings need a grok.com session; skip for BYOK. let settings = match auth { Some(auth) if remote_fetch_enabled => { let _timer = crate::instrumentation_timer!("startup.early_settings_fetch"); crate::remote::fetch_settings_blocking( &endpoints.proxy_url(), auth, endpoints.alpha_test_key.as_deref(), ) } _ => None, }; (models, settings) } /// `remote_fetch_enabled` is a parameter so the pair helper above resolves the /// knob once for both halves. fn prefetch_models_blocking_gated( endpoints: &config::EndpointsConfig, auth: Option<&GrokAuth>, fetch_auth: ModelFetchAuth, remote_fetch_enabled: bool, ) -> Option> { let cache_auth = fetch_auth.cache_auth_method(); // Same URL the fetch below will hit — the cache is only valid for it. let cache_origin = crate::remote::models_list_url(endpoints, fetch_auth); let cache = ModelsCacheManager::new(); if let Some(cached) = cache.load_fresh(&cache_auth, &cache_origin) { return Some(cached.models); } // Every catalog fetch in the product funnels through here, so this single // gate also covers callers that don't go through the prefetch-env check // (leader, headless, stdio, server). Cache above is local and stays usable. if !remote_fetch_enabled { tracing::info!("models fetch skipped: remote_fetch disabled"); return None; } let _timer = crate::instrumentation_timer!("startup.fetch_models_blocking"); match fetch_models_blocking(endpoints, auth, fetch_auth) { Ok(FetchModelsResult { models, etag }) if !models.is_empty() => { let api_base_url_override = match fetch_auth { ModelFetchAuth::ApiKey => Some(endpoints.xai_api_base_url.clone()), _ => None, }; let map = build_prefetched_map(models, api_base_url_override); // NOTE: inheriting context_window / agent_type / api_backend // from hardcoded defaults is handled centrally in // `resolve_model_list` (config.rs), not here. Don't re-add it. tracing::info!(count = map.len(), etag = ?etag, "Prefetched models"); cache.persist(&map, etag.as_deref(), cache_auth, &cache_origin); Some(map) } Ok(FetchModelsResult { .. }) => { tracing::warn!("Models endpoint returned empty list"); None } Err(e) => { tracing::warn!("Failed to fetch models: {:?}", e); None } } } /// Startup prefetch result: models + remote settings. pub struct EarlyPrefetchResult { pub models: Option>, pub settings: Option, } /// Handle for a startup prefetch thread. pub type EarlyPrefetchHandle = std::thread::JoinHandle; struct PrefetchEnv { auth: Option, endpoints: config::EndpointsConfig, model_fetch_auth: ModelFetchAuth, } fn resolve_prefetch_env_with_auth(auth: Option) -> Option { let _timer = crate::instrumentation_timer!("startup.early_prefetch_launch"); // Config-aware (not env-only) so the prefetch can't leak the bearer to api.x.ai. let mut endpoints = config::EndpointsConfig::from_effective_config(); if endpoints.deployment_key.is_none() { endpoints.deployment_key = crate::managed_config::resolve_deployment_key(); } resolve_prefetch_env_from_parts( auth, endpoints, crate::util::config::resolve_remote_fetch_enabled(), ) } /// Decision core of [`resolve_prefetch_env_with_auth`], split from the config /// loading so the gate is unit-testable. /// /// `remote_fetch_enabled = false` wins over every credential shape AND over /// `has_custom_endpoint()` (which otherwise forces the prefetch to run): the /// explicit off switch must hold even when a stray login, `XAI_API_KEY`, or /// `deployment_key` would re-arm the prefetch — and with it the `/v1/settings` /// fetch and the deployment-config sync on the prefetch thread. fn resolve_prefetch_env_from_parts( auth: Option, endpoints: config::EndpointsConfig, remote_fetch_enabled: bool, ) -> Option { if !remote_fetch_enabled { tracing::info!("startup model/settings prefetch skipped: remote_fetch disabled"); return None; } let model_fetch_auth = ModelFetchAuth::resolve(&endpoints, auth.is_some()); if auth.is_none() && !endpoints.has_custom_endpoint() && model_fetch_auth == ModelFetchAuth::Session { return None; } Some(PrefetchEnv { auth, endpoints, model_fetch_auth, }) } fn resolve_prefetch_env(grok_com_config: Option) -> Option { let grok_home = crate::util::grok_home::grok_home(); let auth_manager = AuthManager::new(&grok_home, grok_com_config.unwrap_or_default()); let auth = auth_manager.current(); resolve_prefetch_env_with_auth(auth) } /// Start model + settings prefetch on a background thread using pre-resolved auth. /// /// When the caller has already obtained valid credentials (e.g. via /// `try_ensure_fresh_auth`), pass them here to avoid re-reading stale cached /// credentials from disk. pub fn start_early_prefetch_with_auth(auth: Option) -> Option { let env = resolve_prefetch_env_with_auth(auth)?; Some(spawn_prefetch_thread(env, true)) } /// Start model + settings prefetch on a background thread. /// /// Convenience wrapper that reads cached auth from disk. Prefer /// `start_early_prefetch_with_auth` when you have pre-resolved credentials. /// Also runs a best-effort managed-config sync when the cache is stale. pub fn start_early_prefetch(grok_com_config: Option) -> Option { let env = resolve_prefetch_env(grok_com_config)?; Some(spawn_prefetch_thread(env, true)) } /// Prefetch models + remote settings only — **no** managed-config sync. /// /// Used before the managed-policy gate so a kill-switch can apply on cold start /// without healing a tampered on-disk policy before the fail-closed gate runs. pub fn start_early_prefetch_settings_only( grok_com_config: Option, ) -> Option { let env = resolve_prefetch_env(grok_com_config)?; Some(spawn_prefetch_thread(env, false)) } fn spawn_prefetch_thread(env: PrefetchEnv, sync_managed: bool) -> EarlyPrefetchHandle { std::thread::spawn(move || { let mut timer = crate::instrumentation_timer!("startup.early_prefetch"); let proxy_endpoint = env.endpoints.proxy_url(); timer.with_field("endpoint", proxy_endpoint.as_str()); let (models, settings) = prefetch_models_and_settings_blocking( &env.endpoints, env.auth.as_ref(), env.model_fetch_auth, ); if sync_managed && (env.endpoints.deployment_key.is_some() || crate::managed_config::has_active_team_auth()) && crate::config::is_managed_config_stale_for( &crate::managed_config::current_serving_identity(), ) && crate::managed_config::is_fetch_enabled() && let Ok(rt) = tokio::runtime::Builder::new_current_thread() .enable_all() .build() { crate::managed_config::clear_orphan(); let _ = rt.block_on(crate::managed_config::sync()); } EarlyPrefetchResult { models, settings } }) } /// Map a model id (catalog key or routing slug) to its catalog key. /// /// Sessions persist the routing slug (`[model.X].model`, e.g. `grok-4.5`); /// the catalog and `/model` picker use config keys (e.g. `enterprise-grok-build`). /// Last slug match wins so user overrides beat defaults (matches `MvpAgent::resolve_model_id`). pub(crate) fn resolve_catalog_key( models: &IndexMap, id: &acp::ModelId, ) -> Option { let id_str = id.0.as_ref(); if models.contains_key(id_str) { return Some(id.clone()); } models .iter() .rev() .find(|(_, entry)| entry.info.model == id_str) .map(|(key, _)| acp::ModelId::new(key.clone())) } /// Catalog key for a persisted session model id, restricted to **selectable** /// entries. A selectable exact-key match wins (as in [`resolve_catalog_key`]); /// otherwise the last selectable entry whose routing slug matches `id`, so a /// non-selectable exact-key entry never shadows a selectable slug match. pub(crate) fn selectable_catalog_key_for_persisted( models: &IndexMap, available: &IndexMap, id: &acp::ModelId, ) -> Option { if available.contains_key(id) { return Some(id.clone()); } let id_str = id.0.as_ref(); if let Some((key, _)) = models.iter().rev().find(|(key, entry)| { available.contains_key(&acp::ModelId::new((*key).clone())) && entry.info.model == id_str }) { return Some(acp::ModelId::new(key.clone())); } resolve_catalog_key(models, id).filter(|key| available.contains_key(key)) } /// A "campaign-only" preferred flip: the default changed and either side's value /// is an active campaign default, i.e. the change is attributable to a campaign /// overlay appearing/disappearing rather than a user/CLI/env edit. fn is_campaign_only_flip( old_preferred: &Option, new_preferred: &Option, campaign_defaults: &std::collections::HashSet, ) -> bool { if new_preferred == old_preferred || new_preferred.is_none() { return false; } new_preferred .as_ref() .is_some_and(|p| campaign_defaults.contains(p)) || old_preferred .as_ref() .is_some_and(|p| campaign_defaults.contains(p)) } /// Pick the default model: CLI > env > config > remote-settings hint, falling /// back to the bundled default when the catalog is empty or the preferred /// model isn't present. pub(crate) fn resolve_default_model( cfg: &config::Config, catalog: &IndexMap, is_session_auth: bool, ) -> (String, ModelEntry, config::ConfigSource) { let visible: IndexMap = catalog .iter() .filter(|(_, e)| e.info.visible_for_auth(is_session_auth) && e.info.user_selectable) .map(|(k, v)| (k.clone(), v.clone())) .collect(); let model_pref = config::resolve_string_flag( cfg.default_model_override.as_deref(), "GROK_DEFAULT_MODEL", cfg.models.default.as_deref(), cfg.remote_settings .as_ref() .and_then(|rs| rs.default_model.as_deref()), ); let first_or_fallback = || -> (String, ModelEntry) { if let Some((key, first)) = visible.first() { return (key.clone(), first.clone()); } if let Some((key, entry)) = catalog.iter().find(|(_, e)| e.info.user_selectable) { tracing::warn!("no auth-visible selectable model; using first selectable entry"); return (key.clone(), entry.clone()); } // Pre-catalog/degenerate only: nothing selectable. Set the bundled // default's flag from `allowed_models` so no reader treats it as allowed. tracing::warn!("no selectable models; falling back to bundled default (pre-catalog)"); let default_id = crate::models::default_model().to_string(); let mut entry = ModelEntry::fallback(&default_id, &cfg.endpoints); entry.info.user_selectable = match ModelGlobSet::compile(cfg.models.allowed_models.as_ref()) { Ok(None) => true, Ok(Some(set)) => set.matches(&default_id, &default_id), Err(_) => false, }; (default_id, entry) }; match &model_pref { None => { let (key, first) = first_or_fallback(); (key, first, config::ConfigSource::Default) } Some(pref) => { let found = visible .get_key_value(&pref.value) .or_else(|| visible.iter().find(|(_, m)| m.model == pref.value)); if let Some((key, entry)) = found { (key.clone(), entry.clone(), pref.source) } else { let is_explicit = matches!( pref.source, config::ConfigSource::Cli | config::ConfigSource::Env | config::ConfigSource::Config ); if is_explicit { tracing::warn!( model_id = %pref.value, source = %pref.source, "preferred model not in available models, falling back" ); } else { tracing::debug!( model_id = %pref.value, source = %pref.source, "remote default_model not in available models, skipping" ); } // A campaign default missing from the catalog falls back to the // pre-campaign default before the first-visible fallback. Gated // on the missing pref actually being the campaign-driven config // value — a CLI/env pref that misses the catalog is not a // campaign problem and must not detour through campaign state. let campaign_pref_missing = cfg.models.default_is_campaign_driven && matches!(pref.source, config::ConfigSource::Config); if campaign_pref_missing && let Some(prev) = cfg .models .pre_campaign_default .as_deref() .filter(|s| !s.is_empty()) && let Some((key, entry)) = visible .get_key_value(prev) .or_else(|| visible.iter().find(|(_, m)| m.model == prev)) { tracing::info!( unavailable = %pref.value, fallback = %prev, "campaign-driven default unavailable in catalog; recovering the pre-campaign default" ); return (key.clone(), entry.clone(), config::ConfigSource::Config); } let (key, first) = first_or_fallback(); (key, first, config::ConfigSource::Default) } } } } /// Filter hidden and auth-gated entries out of `catalog` and convert to ACP wire format. pub fn available_models( catalog: &IndexMap, is_session_auth: bool, ) -> IndexMap { let visible: IndexMap = catalog .iter() .filter(|(_, e)| e.info.visible_for_auth(is_session_auth)) .map(|(k, v)| (k.clone(), v.clone())) .collect(); config::to_acp_model_info(&visible) } /// Compiled glob matcher shared by `allowed_models`, `disabled_models`, and /// `hidden_models`. Patterns (globset syntax: `*`, `?`, `[...]`) are matched /// against either the catalog key or the model id. pub(crate) struct ModelGlobSet(GlobSet); impl ModelGlobSet { /// Compile a filter list (`Ok(None)` for `None`/empty). Fails **closed**: an /// invalid pattern returns `Err` listing every bad one for config to reject. pub(crate) fn compile(patterns: Option<&Vec>) -> Result, Vec> { let patterns = match patterns { Some(p) if !p.is_empty() => p, _ => return Ok(None), }; let mut builder = GlobSetBuilder::new(); let mut invalid = Vec::new(); for pat in patterns { match Glob::new(pat) { Ok(glob) => { builder.add(glob); } Err(_) => invalid.push(pat.clone()), } } if !invalid.is_empty() { return Err(invalid); } builder .build() .map(|set| Some(Self(set))) .map_err(|e| vec![e.to_string()]) } fn matches(&self, key: &str, model: &str) -> bool { self.0.is_match(key) || self.0.is_match(model) } } /// Single source of truth for the catalog. Applies, in order: `disabled_models` /// (remove), `allowed_models` (mark `user_selectable`), `hidden_models` (mark /// `hidden`). Special/internal models (web_search, subagents, …) resolve via /// `find_model_by_id`/`models()` and ignore `user_selectable`, so they need no /// exemption. Globs are validated at load (`Config::validate_model_filters`); /// the arms here fail closed if one slips through. pub fn resolve_model_catalog( cfg: &config::Config, prefetched: Option>, ) -> IndexMap { let mut catalog: IndexMap = config::resolve_model_list(cfg, prefetched); if let Ok(Some(disabled)) = ModelGlobSet::compile(cfg.models.disabled_models.as_ref()) { let before = catalog.len(); catalog.retain(|key, entry| !disabled.matches(key, &entry.model)); let removed = before - catalog.len(); if removed > 0 { tracing::info!(count = removed, "disabled_models: removed from catalog"); } } // None/empty allowlist = allow all. match ModelGlobSet::compile(cfg.models.allowed_models.as_ref()) { Ok(None) => { for entry in catalog.values_mut() { entry.info.user_selectable = true; } } Ok(Some(allowed)) => { for (key, entry) in catalog.iter_mut() { entry.info.user_selectable = allowed.matches(key, &entry.model); } } Err(bad) => { tracing::error!(patterns = ?bad, "allowed_models: invalid glob(s); marking nothing selectable"); for entry in catalog.values_mut() { entry.info.user_selectable = false; } } } if let Ok(Some(hidden)) = ModelGlobSet::compile(cfg.models.hidden_models.as_ref()) { for (key, entry) in catalog.iter_mut() { if hidden.matches(key, &entry.model) { entry.info.hidden = true; } } } // Persisted default first; CLI override below wins when set. // Only apply if the model supports reasoning effort. if let Some(effort) = cfg.models.default_reasoning_effort && let Some(default_id) = cfg.models.default.as_deref() && let Some(entry) = catalog.get_mut(default_id) && entry.info.supports_reasoning_effort { entry.info.reasoning_effort = Some(effort); } // Skip non-reasoning models so we don't send the field to providers that reject it. // Also skip models whose effort menu does not include the override (e.g. `--effort none` // must not stamp `none` onto grok-4.5, which only offers low/medium/high). if let Some(effort) = cfg.reasoning_effort_override { for entry in catalog.values_mut() { if model_offers_reasoning_effort(&entry.info, effort) { entry.info.reasoning_effort = Some(effort); } } } catalog } /// Whether `effort` is a value this model will accept on the wire. /// /// Uses the server `reasoning_efforts` menu when present; otherwise the /// built-in low/medium/high/xhigh set (same as the pager legacy menu — no /// `none`/`minimal`). fn model_offers_reasoning_effort(info: &config::ModelInfo, effort: ReasoningEffort) -> bool { if !info.supports_reasoning_effort { return false; } if info.reasoning_efforts.is_empty() { matches!( effort, ReasoningEffort::Low | ReasoningEffort::Medium | ReasoningEffort::High | ReasoningEffort::Xhigh ) } else { info.reasoning_efforts.iter().any(|opt| opt.value == effort) } } /// True when an active `allowed_models` allowlist leaves no selectable model. /// (An excluded *default* does not count — that is recoverable by reselection.) pub(crate) fn allowlist_matches_nothing( cfg: &config::Config, catalog: &IndexMap, ) -> bool { cfg.models .allowed_models .as_ref() .is_some_and(|a| !a.is_empty()) && !catalog.values().any(|e| e.info.user_selectable) } /// Reject an `allowed_models` allowlist that leaves no selectable model, or that /// excludes an explicitly configured default (`default`/`-m`). Run only against a /// real catalog (cache/prefetch/fetched), not the bundled bootstrap set. pub(crate) fn validate_selectable( cfg: &config::Config, catalog: &IndexMap, ) -> Result<(), String> { let Some(allowed) = cfg.models.allowed_models.as_ref().filter(|a| !a.is_empty()) else { return Ok(()); }; let patterns = allowed.join(", "); if !catalog.values().any(|e| e.info.user_selectable) { return Err(format!( "None of your available models match allowed_models ({patterns}). \ Broaden the patterns or remove allowed_models, then try again." )); } for (src, id) in [ ("default", cfg.models.default.as_deref()), ("-m flag", cfg.default_model_override.as_deref()), ] { if let Some(id) = id && let Some(entry) = catalog .get(id) .or_else(|| catalog.values().find(|e| e.model == id)) && !entry.info.user_selectable { return Err(format!( "\"{id}\" (your {src}) isn't allowed by allowed_models ({patterns}). \ Add it to allowed_models, or set a different model." )); } } Ok(()) } /// Async wrapper around `prefetch_models_blocking`. pub(crate) async fn fetch_models_async( endpoints: config::EndpointsConfig, auth: Option, fetch_auth: ModelFetchAuth, ) -> Option> { tokio::task::spawn_blocking(move || { prefetch_models_blocking(&endpoints, auth.as_ref(), fetch_auth) }) .await .unwrap_or(None) } #[cfg(test)] mod tests { use super::*; fn test_manager() -> ModelsManager { let _ = tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_test_writer() .try_init(); // Use a temp dir so AuthManager finds no credentials — ensures // refresh_async bails at the auth check without needing a tokio runtime. let tmp = std::env::temp_dir().join("grok-test-models-manager"); let auth_manager = Arc::new(AuthManager::new(&tmp, GrokComConfig::default())); ModelsManager::new( None, IndexMap::new(), acp::ModelId::new("default"), auth_manager, config::Config::default(), ) } fn config_from_toml(toml: &str) -> config::Config { config::Config::new_from_toml_cfg(&toml::from_str(toml).unwrap()).unwrap() } #[test] fn model_show_model_fingerprint_reads_catalog_flag() { let mgr = test_manager(); // Entry with the catalog flag set → accessor returns true. let mut flagged = ModelEntry { info: config::ModelInfo::fallback("fp-model"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; flagged.info.show_model_fingerprint = true; mgr.insert_test_entry("fp-model", flagged); // Entry without the flag → defaults false. mgr.insert_test_entry( "plain-model", ModelEntry { info: config::ModelInfo::fallback("plain-model"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }, ); // Catalog KEY differs from the routing SLUG (custom/enterprise id): the // map is keyed "enterprise-key" but the model slug is "enterprise-slug". let mut custom = ModelEntry { info: config::ModelInfo::fallback("enterprise-slug"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; custom.info.show_model_fingerprint = true; mgr.insert_test_entry("enterprise-key", custom); assert!(mgr.model_show_model_fingerprint("fp-model")); assert!(!mgr.model_show_model_fingerprint("plain-model")); // Unknown model id → false (no catalog entry). assert!(!mgr.model_show_model_fingerprint("missing-model")); // Lookup by the routing SLUG must resolve to the differing catalog KEY — // a direct `.get(slug)` would miss this entry and wrongly return false. assert!( mgr.model_show_model_fingerprint("enterprise-slug"), "slug lookup must resolve to the catalog key and read the flag", ); // Lookup by the catalog KEY itself still works (exact-match path). assert!(mgr.model_show_model_fingerprint("enterprise-key")); } /// The active model must be selectable, not the first entry of the /// un-allowlisted catalog. #[test] fn default_model_honors_allowlist_when_no_default_set() { let cfg = config_from_toml( r#" [models] allowed_models = ["keep-*"] [model.zzz-first] model = "zzz-first" base_url = "https://api.x.ai/v1" context_window = 256000 [model.keep-one] model = "keep-one" base_url = "https://api.x.ai/v1" context_window = 256000 "#, ); let catalog = resolve_model_catalog(&cfg, None); let (_key, entry, _src) = resolve_default_model(&cfg, &catalog, true); assert!( entry.info.user_selectable, "picked non-selectable {}", entry.model ); } #[test] fn validate_selectable_rejects_bad_allowlists() { // Excluded explicit default → error names the default. let excluded = config_from_toml( r#" [models] default = "grok-3" allowed_models = ["grok-4*"] [model.grok-3] model = "grok-3" base_url = "https://api.x.ai/v1" context_window = 256000 [model.grok-4] model = "grok-4" base_url = "https://api.x.ai/v1" context_window = 256000 "#, ); let catalog = resolve_model_catalog(&excluded, None); assert!( validate_selectable(&excluded, &catalog) .unwrap_err() .contains("grok-3") ); // Matches nothing → error. let zero = config_from_toml( r#" [models] allowed_models = ["nomatch-*"] [model.grok-4] model = "grok-4" base_url = "https://api.x.ai/v1" context_window = 256000 "#, ); let catalog = resolve_model_catalog(&zero, None); assert!(validate_selectable(&zero, &catalog).is_err()); } #[tokio::test] async fn refresh_if_new_etag_skips_when_same() { let mgr = test_manager(); // Set initial etag *mgr.inner.etag.write() = Some("\"abc123\"".to_string()); // Same etag — should be a no-op (etag stays the same) mgr.refresh_if_new_etag("\"abc123\"".to_string()).await; assert_eq!( mgr.inner.etag.read().as_deref(), Some("\"abc123\""), "etag should remain unchanged when same" ); } #[tokio::test] async fn set_current_model_id_change_fires_watch_to_all_subscribers() { // Two subscribers (simulating two SessionActors sharing one // ModelsManager catalog) both observe the change. Fast-path // "same id" must NOT bump the generation. let mgr = test_manager(); let mut rx_a = mgr.subscribe_model_switch(); let mut rx_b = mgr.subscribe_model_switch(); let initial_a = *rx_a.borrow_and_update(); let initial_b = *rx_b.borrow_and_update(); assert_eq!(initial_a, initial_b); // Same id is the fast path — no bump. mgr.set_current_model_id(acp::ModelId::new("default")); // Force-yield so any spurious wakeup would have a chance to // surface. `try_recv` on a watch channel: use a timeout-zero // race; if `.changed()` resolves within 25ms we have a bug. let same_id_ticked = tokio::time::timeout(std::time::Duration::from_millis(25), rx_a.changed()) .await .is_ok(); assert!( !same_id_ticked, "set_current_model_id(same id) must NOT bump the watch generation", ); // Real switch: both subscribers see the change. mgr.set_current_model_id(acp::ModelId::new("grok-4")); tokio::time::timeout(std::time::Duration::from_millis(100), rx_a.changed()) .await .expect("rx_a saw the switch") .expect("watch channel still open"); tokio::time::timeout(std::time::Duration::from_millis(100), rx_b.changed()) .await .expect("rx_b saw the switch") .expect("watch channel still open"); assert_ne!(*rx_a.borrow(), initial_a); assert_eq!(*rx_a.borrow(), *rx_b.borrow()); assert!(mgr.model_switch_generation() > initial_a); } #[tokio::test] async fn model_switch_generation_snapshot_reflects_current_state() { let mgr = test_manager(); let start = mgr.model_switch_generation(); mgr.set_current_model_id(acp::ModelId::new("grok-4")); assert_eq!(mgr.model_switch_generation(), start + 1); // Idempotent: same id → no bump. mgr.set_current_model_id(acp::ModelId::new("grok-4")); assert_eq!(mgr.model_switch_generation(), start + 1); // Another real change: another bump. mgr.set_current_model_id(acp::ModelId::new("grok-3")); assert_eq!(mgr.model_switch_generation(), start + 2); } #[test] fn rebuild_updates_models_and_available() { let mgr = test_manager(); assert!(mgr.models().is_empty()); assert!(mgr.available().is_empty()); let cfg = config::Config::default(); let mut prefetched = IndexMap::new(); prefetched.insert( "test-model".to_string(), ModelEntry { info: config::ModelInfo::fallback("test-model"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }, ); mgr.rebuild(&cfg, Some(prefetched)); assert!( !mgr.models().is_empty(), "models should be populated after rebuild" ); } #[test] fn current_reasoning_effort_round_trip() { let mgr = test_manager(); assert_eq!(mgr.current_reasoning_effort(), None); mgr.set_current_reasoning_effort(Some(ReasoningEffort::High)); assert_eq!(mgr.current_reasoning_effort(), Some(ReasoningEffort::High)); mgr.set_current_reasoning_effort(None); assert_eq!(mgr.current_reasoning_effort(), None); } #[test] fn current_reasoning_effort_seeded_from_config() { let tmp = std::env::temp_dir().join("grok-test-models-manager-seed"); let auth_manager = Arc::new(AuthManager::new(&tmp, GrokComConfig::default())); let mut cfg = config::Config::default(); cfg.models.default_reasoning_effort = Some(ReasoningEffort::Xhigh); let mgr = ModelsManager::new( None, IndexMap::new(), acp::ModelId::new("default"), auth_manager, cfg, ); assert_eq!(mgr.current_reasoning_effort(), Some(ReasoningEffort::Xhigh),); } #[test] fn default_reasoning_effort_only_stamps_supporting_model() { use indexmap::IndexMap; // Model that supports reasoning effort — effort should be applied. let mut cfg = config::Config::default(); cfg.models.default = Some("reasoning-model".to_string()); cfg.models.default_reasoning_effort = Some(ReasoningEffort::High); let mut prefetched = IndexMap::new(); let mut reasoning_entry = ModelEntry { info: config::ModelInfo::fallback("reasoning-model"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; reasoning_entry.info.supports_reasoning_effort = true; prefetched.insert("reasoning-model".to_string(), reasoning_entry); let catalog = resolve_model_catalog(&cfg, Some(prefetched)); assert_eq!( catalog["reasoning-model"].info.reasoning_effort, Some(ReasoningEffort::High), "reasoning-supporting default model should be stamped", ); // Model that does NOT support reasoning effort — effort must NOT be applied. let mut cfg = config::Config::default(); cfg.models.default = Some("plain-model".to_string()); cfg.models.default_reasoning_effort = Some(ReasoningEffort::High); let mut prefetched = IndexMap::new(); let plain_entry = ModelEntry { info: config::ModelInfo::fallback("plain-model"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; prefetched.insert("plain-model".to_string(), plain_entry); let catalog = resolve_model_catalog(&cfg, Some(prefetched)); assert_eq!( catalog["plain-model"].info.reasoning_effort, None, "non-reasoning default model must NOT be stamped with persisted effort", ); } #[test] fn reasoning_effort_override_skips_models_that_do_not_offer_level() { use indexmap::IndexMap; use xai_grok_sampling_types::ReasoningEffortOption; let cfg = config::Config { reasoning_effort_override: Some(ReasoningEffort::None), ..Default::default() }; let mut prefetched = IndexMap::new(); // 4.5-style: supports effort, menu is high only (no none). let mut no_none = ModelEntry { info: config::ModelInfo::fallback("grok-4.5"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; no_none.info.supports_reasoning_effort = true; no_none.info.reasoning_efforts = vec![ReasoningEffortOption { id: "high".into(), value: ReasoningEffort::High, label: "High".into(), description: None, default: true, }]; no_none.info.reasoning_effort = Some(ReasoningEffort::High); prefetched.insert("grok-4.5".to_string(), no_none); // Model that explicitly offers none. let mut with_none = ModelEntry { info: config::ModelInfo::fallback("legacy-none"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; with_none.info.supports_reasoning_effort = true; with_none.info.reasoning_efforts = vec![ReasoningEffortOption { id: "none".into(), value: ReasoningEffort::None, label: "None".into(), description: None, default: true, }]; prefetched.insert("legacy-none".to_string(), with_none); let catalog = resolve_model_catalog(&cfg, Some(prefetched)); assert_eq!( catalog["grok-4.5"].info.reasoning_effort, Some(ReasoningEffort::High), "--effort none must not stamp onto models that do not offer none" ); assert_eq!( catalog["legacy-none"].info.reasoning_effort, Some(ReasoningEffort::None), "models that list none should still accept the override" ); } #[test] fn config_menu_only_model_derives_support_and_default() { // The config-TOML path: a model configured with ONLY `reasoning_efforts` // (no `supports_reasoning_effort`, no scalar `reasoning_effort`) must read // as supported with the marked-default option's value on the internal // gates that BugBot flagged (support gate + wire default). let mut cfg = config::Config::default(); cfg.config_models.insert( "menu-only".to_string(), config::ConfigModelOverride { reasoning_efforts: vec![ ReasoningEffortOption { id: "balanced".to_string(), value: ReasoningEffort::Medium, label: "Balanced".to_string(), description: None, default: false, }, ReasoningEffortOption { id: "deep".to_string(), value: ReasoningEffort::Xhigh, label: "Deep".to_string(), description: None, default: true, }, ], ..Default::default() }, ); // A sibling with no menu must stay underived (empty-list path unchanged). cfg.config_models .insert("plain".to_string(), config::ConfigModelOverride::default()); let catalog = resolve_model_catalog(&cfg, None); let info = &catalog["menu-only"].info; assert!( info.supports_reasoning_effort, "menu-only model must derive support" ); assert_eq!( info.reasoning_effort, Some(ReasoningEffort::Xhigh), "derived default = marked-default option value" ); assert!(!catalog["plain"].info.supports_reasoning_effort); assert_eq!(catalog["plain"].info.reasoning_effort, None); // The internal getters read those derived fields. let tmp = std::env::temp_dir().join("grok-test-models-manager-menu-only"); let auth_manager = Arc::new(AuthManager::new(&tmp, GrokComConfig::default())); let mgr = ModelsManager::new( None, catalog, acp::ModelId::new("menu-only"), auth_manager, cfg, ); assert!(mgr.model_supports_reasoning_effort("menu-only")); assert_eq!( mgr.model_default_reasoning_effort("menu-only"), Some(ReasoningEffort::Xhigh) ); assert_eq!(mgr.model_reasoning_efforts("menu-only").len(), 2); assert!(!mgr.model_supports_reasoning_effort("plain")); assert_eq!(mgr.model_default_reasoning_effort("plain"), None); } #[test] fn cli_reasoning_effort_override_only_stamps_supporting_models() { use indexmap::IndexMap; let cfg = config::Config { reasoning_effort_override: Some(ReasoningEffort::High), ..config::Config::default() }; let mut prefetched = IndexMap::new(); let mut reasoning_entry = ModelEntry { info: config::ModelInfo::fallback("reasoning-model"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; reasoning_entry.info.supports_reasoning_effort = true; prefetched.insert("reasoning-model".to_string(), reasoning_entry); let plain_entry = ModelEntry { info: config::ModelInfo::fallback("plain-model"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; prefetched.insert("plain-model".to_string(), plain_entry); let catalog = resolve_model_catalog(&cfg, Some(prefetched)); assert_eq!( catalog["reasoning-model"].info.reasoning_effort, Some(ReasoningEffort::High), "reasoning-supporting model should be stamped", ); assert_eq!( catalog["plain-model"].info.reasoning_effort, None, "non-reasoning model must NOT be stamped", ); } #[test] fn apply_refresh_result_only_updates_etag_on_success() { let mgr = test_manager(); let cfg = config::Config::default(); *mgr.inner.etag.write() = Some("\"old\"".to_string()); assert!( !mgr.apply_refresh_result(&cfg, None, Some("\"new\"".to_string())), "failed refresh should report no update" ); assert_eq!( mgr.inner.etag.read().as_deref(), Some("\"old\""), "etag should remain unchanged when refresh fails" ); assert!( mgr.prefetched().is_none(), "prefetched models should stay unchanged" ); } fn make_model_entry(model_id: &str) -> ModelEntry { ModelEntry { info: config::ModelInfo::fallback(model_id), api_key: None, env_key: None, auth_provider: None, api_base_url: None, } } fn make_prefetched(ids: &[&str]) -> IndexMap { ids.iter() .map(|id| (id.to_string(), make_model_entry(id))) .collect() } // ── auth-change refresh: has_fetched_real_catalog flag ───────────── #[test] fn first_apply_refresh_reselects_default_model() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-3".to_string()); assert!(!mgr.has_fetched_real_catalog()); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); assert!(mgr.has_fetched_real_catalog()); assert_eq!(mgr.current_model_id().0.as_ref(), "grok-3"); } #[test] fn subsequent_apply_refresh_preserves_user_model() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-3".to_string()); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); mgr.set_current_model_id(acp::ModelId::new("grok-4")); // Simulate on_auth_changed clearing prefetched + etag. *mgr.inner.prefetched.write() = None; *mgr.inner.etag.write() = None; let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); assert_eq!( mgr.current_model_id().0.as_ref(), "grok-4", "user's model selection must survive auth-change refresh" ); } #[test] fn subsequent_refresh_reselects_when_model_removed() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-3".to_string()); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); mgr.set_current_model_id(acp::ModelId::new("grok-4")); // Second refresh with grok-4 removed. let prefetched = make_prefetched(&["grok-3", "grok-4.5"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); assert_eq!( mgr.current_model_id().0.as_ref(), "grok-3", "should fall back to config default when current is removed" ); } #[test] fn failed_refresh_does_not_set_has_fetched_real_catalog() { let mgr = test_manager(); let cfg = config::Config::default(); mgr.apply_refresh_result(&cfg, None, None); assert!( !mgr.has_fetched_real_catalog(), "failed refresh must not flip has_fetched_real_catalog" ); } // ── apply_config: honor changed preferred model from config ──────── #[test] fn apply_config_honors_new_preferred_model() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-3".to_string()); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); mgr.set_current_model_id(acp::ModelId::new("grok-4")); // Simulate stale inner cfg (no default) from a racing auth refresh. let mut stale_cfg = config::Config::default(); stale_cfg.models.default = None; *mgr.inner.cfg.write() = stale_cfg; let mut new_cfg = config::Config::default(); new_cfg.models.default = Some("grok-3".to_string()); mgr.apply_config(new_cfg); assert_eq!( mgr.current_model_id().0.as_ref(), "grok-3", "apply_config must honor updated preferred model from config" ); } #[test] fn apply_config_preserves_current_when_preferred_unchanged() { let mgr = test_manager(); let cfg = config::Config::default(); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); mgr.set_current_model_id(acp::ModelId::new("grok-4")); // Unrelated config change — preferred model unchanged. let new_cfg = config::Config::default(); mgr.apply_config(new_cfg); assert_eq!( mgr.current_model_id().0.as_ref(), "grok-4", "apply_config must not reset model when preferred hasn't changed" ); } #[test] fn apply_config_falls_back_when_preferred_not_in_catalog() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-3".to_string()); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); mgr.set_current_model_id(acp::ModelId::new("grok-4")); // Preferred model not in catalog — falls back to first entry. let mut new_cfg = config::Config::default(); new_cfg.models.default = Some("grok-nonexistent".to_string()); mgr.apply_config(new_cfg); let current = mgr.current_model_id(); let first_available = mgr.available().keys().next().unwrap().clone(); assert_eq!( current.0.as_ref(), first_available.0.as_ref(), "should fall back to first visible model when preferred not in catalog" ); } #[test] fn apply_config_both_none_preferred_preserves_current() { let mgr = test_manager(); let cfg = config::Config::default(); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); mgr.set_current_model_id(acp::ModelId::new("grok-4")); let new_cfg = config::Config::default(); mgr.apply_config(new_cfg); assert_eq!( mgr.current_model_id().0.as_ref(), "grok-4", "both-None preferred must preserve user's runtime model" ); } #[test] fn apply_config_old_some_new_none_preserves_current() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-3".to_string()); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); assert_eq!(mgr.current_model_id().0.as_ref(), "grok-3"); mgr.set_current_model_id(acp::ModelId::new("grok-4")); // [models] default removed — is_some() guard prevents reset. let new_cfg = config::Config::default(); mgr.apply_config(new_cfg); assert_eq!( mgr.current_model_id().0.as_ref(), "grok-4", "old=Some new=None must not reset model (is_some guard)" ); } // ── end-to-end: auth refresh + config reload compose correctly ─── #[test] fn auth_refresh_then_config_reload_preserves_user_model() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-3".to_string()); // Initial fetch. let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); // User runs /model grok-4. mgr.set_current_model_id(acp::ModelId::new("grok-4")); // Auth refresh races — clears prefetched/etag. *mgr.inner.prefetched.write() = None; *mgr.inner.etag.write() = None; // Second fetch must preserve user's model. let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); assert_eq!(mgr.current_model_id().0.as_ref(), "grok-4"); // Config reload with persisted preference. let mut new_cfg = config::Config::default(); new_cfg.models.default = Some("grok-4".to_string()); mgr.apply_config(new_cfg); assert_eq!(mgr.current_model_id().0.as_ref(), "grok-4"); } // ── disk-cache hot-reload (external models_cache.json writes) ──── fn test_cache_manager(dir: &std::path::Path) -> ModelsCacheManager { ModelsCacheManager { path: dir.join(MODELS_CACHE_FILE), ttl: CACHE_TTL, } } /// An external process persisting a fresh catalog must be picked up: /// catalog swapped, etag adopted, real-catalog flag set. #[test] fn reload_from_disk_cache_applies_external_catalog() { let mgr = test_manager(); let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let auth_method = mgr.inner.fetch_auth.read().cache_auth_method(); cache.persist( &make_prefetched(&["grok-4.5", "grok-4.3"]), Some("etag-ext"), auth_method, &mgr.cache_origin(), ); mgr.reload_from_cache_manager(&cache); assert!(mgr.has_fetched_real_catalog()); assert!(mgr.models().contains_key("grok-4.5")); assert!(mgr.models().contains_key("grok-4.3")); assert_eq!(mgr.inner.etag.read().as_deref(), Some("etag-ext")); } /// A latched "allowlist excludes everything" prompt block must clear when /// an external cache write delivers a catalog the allowlist matches — /// `reload_from_cache_manager` recomputes `allowlist_excludes_all` after /// the rebuild, like `apply_refresh_result` does. #[test] fn reload_from_disk_cache_recomputes_allowlist_excludes_all() { let mgr = test_manager(); let cfg = config_from_toml("[models]\nallowed_models = [\"keep-*\"]"); // Latch the flag: neither the fetched model nor the bundled defaults // merged by `resolve_model_catalog` match `keep-*`. mgr.apply_refresh_result(&cfg, Some(make_prefetched(&["other-1"])), None); assert!( mgr.allowlist_excludes_all(), "setup: allowlist should exclude the entire catalog" ); // `apply_refresh_result` borrows the config without storing it, while // `reload_from_cache_manager` reads `inner.cfg` — install it there. *mgr.inner.cfg.write() = cfg.clone(); // External process persists a catalog containing an allowed model. let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let auth_method = mgr.inner.fetch_auth.read().cache_auth_method(); cache.persist( &make_prefetched(&["keep-1"]), Some("etag-keep"), auth_method, &mgr.cache_origin(), ); mgr.reload_from_cache_manager(&cache); assert!(mgr.models().contains_key("keep-1")); assert!( !mgr.allowlist_excludes_all(), "corrective external cache write must unlatch the prompt block" ); } /// When the *first* real catalog arrives via an external cache write (the /// leader never completed its own fetch), the configured `[models]` /// default must be resolved — mirroring `apply_refresh_result`'s /// first-catalog branch — instead of staying on the bundled placeholder. #[test] fn reload_from_disk_cache_resolves_default_on_first_catalog() { let mgr = test_manager(); assert!(!mgr.has_fetched_real_catalog()); let cfg = config_from_toml("[models]\ndefault = \"keep-1\""); // `reload_from_cache_manager` reads the manager's stored config. *mgr.inner.cfg.write() = cfg.clone(); let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let auth_method = mgr.inner.fetch_auth.read().cache_auth_method(); cache.persist( &make_prefetched(&["keep-1", "other-1"]), Some("etag-first"), auth_method, &mgr.cache_origin(), ); mgr.reload_from_cache_manager(&cache); assert!(mgr.has_fetched_real_catalog()); assert_eq!( mgr.current_model_id().0.as_ref(), "keep-1", "first real catalog must resolve the configured default" ); } /// A cache write whose catalog matches the in-memory prefetched map (the /// leader's own `persist`/`renew_ttl` self-writes, or a same-content fetch /// by another process) must be a no-op apart from adopting the etag — no /// rebuild, no model reselection. #[test] fn reload_from_disk_cache_skips_identical_catalog_and_adopts_etag() { let mgr = test_manager(); let cfg = config::Config::default(); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched.clone()), Some("etag-a".into())); mgr.set_current_model_id(acp::ModelId::new("grok-4")); let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let auth_method = mgr.inner.fetch_auth.read().cache_auth_method(); cache.persist( &prefetched, Some("etag-b"), auth_method, &mgr.cache_origin(), ); mgr.reload_from_cache_manager(&cache); assert_eq!( mgr.current_model_id().0.as_ref(), "grok-4", "identical catalog must not disturb the user's model" ); assert_eq!( mgr.inner.etag.read().as_deref(), Some("etag-b"), "etag should be adopted so refresh_if_new_etag stays accurate" ); } /// A cache file older than the TTL is rejected by `load_fresh` — the /// watcher event arrives within the debounce window of the write, so a /// stale file means the write was not a fresh fetch. #[test] fn reload_from_disk_cache_ignores_stale_cache() { let mgr = test_manager(); let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let auth_method = mgr.inner.fetch_auth.read().cache_auth_method(); let stale = ModelsCache { fetched_at: Utc::now() - ChronoDuration::seconds(3600), grok_version: Some(xai_grok_version::VERSION.to_string()), auth_method: Some(auth_method), origin: Some(mgr.cache_origin()), etag: Some("etag-stale".into()), models: make_prefetched(&["grok-stale"]), }; cache.atomic_write(&stale); mgr.reload_from_cache_manager(&cache); assert!(!mgr.models().contains_key("grok-stale")); assert!(mgr.inner.etag.read().is_none()); } /// A cache persisted by a process running with different credentials /// (e.g. an API-key `--no-leader` run next to a session-auth leader) /// must not poison this manager's catalog. #[test] fn reload_from_disk_cache_ignores_auth_method_mismatch() { let mgr = test_manager(); let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let current = mgr.inner.fetch_auth.read().cache_auth_method(); let other = if current == CacheAuthMethod::Session { CacheAuthMethod::ApiKey } else { CacheAuthMethod::Session }; cache.persist( &make_prefetched(&["grok-other-auth"]), Some("etag-x"), other, &mgr.cache_origin(), ); mgr.reload_from_cache_manager(&cache); assert!(!mgr.models().contains_key("grok-other-auth")); } /// A cache persisted by a process pointed at a *different backend* (env /// override, another deployment, a test's mock server) must not poison /// this manager's catalog: cached entries embed absolute `base_url`s from /// their origin, so adopting them silently re-points inference. This is /// the windows-x86_64 lifecycle e2e failure mode — the shared-profile /// cache from test 1's mock sent test 2's prompts to a dead port. #[test] fn reload_from_disk_cache_ignores_origin_mismatch() { let mgr = test_manager(); let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let auth_method = mgr.inner.fetch_auth.read().cache_auth_method(); cache.persist( &make_prefetched(&["grok-other-origin"]), Some("etag-y"), auth_method, "http://127.0.0.1:49953/v1/models", ); mgr.reload_from_cache_manager(&cache); assert!(!mgr.models().contains_key("grok-other-origin")); assert!(mgr.inner.etag.read().is_none()); } /// A legacy cache file written before the `origin` field existed must be /// treated as a miss (`None` origin never matches) — its entries could /// have come from anywhere. #[test] fn reload_from_disk_cache_ignores_legacy_cache_without_origin() { let mgr = test_manager(); let tmp = tempfile::TempDir::new().unwrap(); let cache = test_cache_manager(tmp.path()); let auth_method = mgr.inner.fetch_auth.read().cache_auth_method(); let legacy = ModelsCache { fetched_at: Utc::now(), grok_version: Some(xai_grok_version::VERSION.to_string()), auth_method: Some(auth_method), origin: None, etag: Some("etag-legacy".into()), models: make_prefetched(&["grok-legacy"]), }; cache.atomic_write(&legacy); mgr.reload_from_cache_manager(&cache); assert!(!mgr.models().contains_key("grok-legacy")); } // ── clear() resets has_fetched_real_catalog ────────────────────── #[test] fn clear_resets_has_fetched_real_catalog() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-3".to_string()); let prefetched = make_prefetched(&["grok-3", "grok-4"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); assert!(mgr.has_fetched_real_catalog()); mgr.clear(); assert!(!mgr.has_fetched_real_catalog()); // New identity fetch — resolves default via reselect_default_model. let prefetched = make_prefetched(&["grok-4.5", "grok-4.3"]); mgr.apply_refresh_result(&cfg, Some(prefetched), None); let first_available = mgr.available().keys().next().unwrap().clone(); assert_eq!( mgr.current_model_id().0.as_ref(), first_available.0.as_ref() ); } /// A flip is "campaign-only" iff the preferred changed and either side is an /// active campaign default. #[test] fn is_campaign_only_flip_detects_campaign_driven_changes() { let camp: std::collections::HashSet = ["beta".into()].into_iter().collect(); // New side is the campaign default (campaign appearing) → campaign-only. assert!(is_campaign_only_flip( &Some("alpha".into()), &Some("beta".into()), &camp )); // Old side was the campaign default (campaign withdrawing) → campaign-only. assert!(is_campaign_only_flip( &Some("beta".into()), &Some("alpha".into()), &camp )); // Neither side a campaign default → ordinary user/CLI/env flip. assert!(!is_campaign_only_flip( &Some("alpha".into()), &Some("gamma".into()), &camp )); // No change, cleared default, or empty campaign set → never campaign-only. assert!(!is_campaign_only_flip( &Some("beta".into()), &Some("beta".into()), &camp )); assert!(!is_campaign_only_flip(&Some("beta".into()), &None, &camp)); assert!(!is_campaign_only_flip( &Some("alpha".into()), &Some("beta".into()), &std::collections::HashSet::new() )); } /// A campaign-only flip must NOT reselect a live session whose current model /// is still selectable; a non-campaign flip must. "Campaign-driven" is marked /// by `default_is_campaign_driven` on the incoming config. #[test] fn campaign_only_flip_does_not_reselect_live_session() { let mgr = test_manager(); let mut cfg = config::Config::default(); cfg.models.default = Some("alpha".to_string()); mgr.apply_refresh_result(&cfg, Some(make_prefetched(&["alpha", "beta"])), None); *mgr.inner.cfg.write() = cfg.clone(); // old_preferred = "alpha" assert_eq!(mgr.current_model_id().0.as_ref(), "alpha"); let mut new_cfg = config::Config::default(); new_cfg.models.default = Some("beta".to_string()); new_cfg.models.default_is_campaign_driven = true; // campaign overriding mgr.apply_config(new_cfg); assert_eq!( mgr.current_model_id().0.as_ref(), "alpha", "campaign-only flip must not yank a still-selectable live session" ); // Control: same flip with no campaign (no pre_campaign_default) → reselect. let mgr2 = test_manager(); let mut cfg2 = config::Config::default(); cfg2.models.default = Some("alpha".to_string()); mgr2.apply_refresh_result(&cfg2, Some(make_prefetched(&["alpha", "beta"])), None); *mgr2.inner.cfg.write() = cfg2.clone(); let mut new_cfg2 = config::Config::default(); new_cfg2.models.default = Some("beta".to_string()); mgr2.apply_config(new_cfg2); assert_eq!( mgr2.current_model_id().0.as_ref(), "beta", "a non-campaign preferred change must reselect" ); } /// A campaign default missing from the catalog falls back to /// `pre_campaign_default`, then to the first visible model — and only when /// the missing pref is actually the campaign-driven config value. #[test] fn unavailable_campaign_default_falls_back_to_config_default() { let catalog = make_prefetched(&["real-model", "other-model"]); let mut cfg = config::Config::default(); cfg.models.default = Some("missing-model".to_string()); cfg.models.default_is_campaign_driven = true; cfg.models.pre_campaign_default = Some("real-model".to_string()); let (key, _, _) = resolve_default_model(&cfg, &catalog, true); assert_eq!( key, "real-model", "must fall back to the pre-campaign default" ); // Control: pre-campaign default also absent → first visible model. let mut cfg2 = config::Config::default(); cfg2.models.default = Some("missing-model".to_string()); cfg2.models.default_is_campaign_driven = true; cfg2.models.pre_campaign_default = Some("also-missing".to_string()); let (key2, _, _) = resolve_default_model(&cfg2, &catalog, true); assert_eq!(&key2, catalog.keys().next().unwrap()); // Control: not campaign-driven (e.g. stale recovery value alongside a // user-set default) → the campaign detour must NOT fire; a missing // config pref falls to the first visible model. let mut cfg3 = config::Config::default(); cfg3.models.default = Some("missing-model".to_string()); cfg3.models.pre_campaign_default = Some("real-model".to_string()); let (key3, _, _) = resolve_default_model(&cfg3, &catalog, true); assert_eq!( &key3, catalog.keys().next().unwrap(), "non-campaign catalog miss must not recover via campaign state" ); // Control: CLI override misses the catalog while campaign state is set // → CLI is not a campaign problem; no campaign detour. let mut cfg4 = config::Config { default_model_override: Some("missing-cli-model".to_string()), ..Default::default() }; cfg4.models.default = Some("campaign-model".to_string()); cfg4.models.default_is_campaign_driven = true; cfg4.models.pre_campaign_default = Some("real-model".to_string()); let (key4, _, _) = resolve_default_model(&cfg4, &catalog, true); assert_eq!( &key4, catalog.keys().next().unwrap(), "a CLI pref miss must not detour through pre_campaign_default" ); } // ── ModelFetchAuth::resolve priority tests ────────────────────── use serial_test::serial; use xai_grok_test_support::EnvGuard; #[test] #[serial] fn resolve_custom_endpoint_always_wins() { let _key = EnvGuard::set("XAI_API_KEY", "test-key"); let endpoints = config::EndpointsConfig { models_base_url: Some("https://custom.example.com".to_owned()), ..config::EndpointsConfig::default() }; assert_eq!( ModelFetchAuth::resolve(&endpoints, true), ModelFetchAuth::CustomEndpoint, ); assert_eq!( ModelFetchAuth::resolve(&endpoints, false), ModelFetchAuth::CustomEndpoint, ); } #[test] #[serial] fn resolve_cached_session_wins_over_api_key() { let _key = EnvGuard::set("XAI_API_KEY", "test-key"); let endpoints = config::EndpointsConfig::default(); assert_eq!( ModelFetchAuth::resolve(&endpoints, true), ModelFetchAuth::Session, "cached session should take priority over API key", ); } #[test] #[serial] fn resolve_api_key_used_when_no_session() { let _key = EnvGuard::set("XAI_API_KEY", "test-key"); let endpoints = config::EndpointsConfig::default(); assert_eq!( ModelFetchAuth::resolve(&endpoints, false), ModelFetchAuth::ApiKey, "API key should be used when no cached session exists", ); } #[test] #[serial] fn resolve_falls_back_to_session_when_nothing_set() { let _unset = EnvGuard::unset("XAI_API_KEY"); let _unset_legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY"); let endpoints = config::EndpointsConfig::default(); assert_eq!( ModelFetchAuth::resolve(&endpoints, false), ModelFetchAuth::Session, "should fall back to Session when nothing else is configured", ); } #[test] #[serial] fn resolve_deployment_key_when_no_session_or_api_key() { let _unset = EnvGuard::unset("XAI_API_KEY"); let _unset_legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY"); let endpoints = config::EndpointsConfig { deployment_key: Some("deploy-key".to_owned()), ..config::EndpointsConfig::default() }; assert_eq!( ModelFetchAuth::resolve(&endpoints, false), ModelFetchAuth::Deployment, ); } /// `deployment_key` outranks a stray `XAI_API_KEY`, but session wins over both. #[test] #[serial] fn resolve_deployment_key_outranks_ambient_api_key() { let _key = EnvGuard::set("XAI_API_KEY", "stray-env-key"); let endpoints = config::EndpointsConfig { deployment_key: Some("deploy-key".to_owned()), ..config::EndpointsConfig::default() }; assert_eq!( ModelFetchAuth::resolve(&endpoints, false), ModelFetchAuth::Deployment, "managed deployment_key should outrank an ambient XAI_API_KEY", ); assert_eq!( ModelFetchAuth::resolve(&endpoints, true), ModelFetchAuth::Session, "an active session should still win over a managed deployment", ); } // ── remote_fetch gate: resolve_prefetch_env_from_parts ─────────── /// remote_fetch=false must return `None` against every re-arming shape at /// once — session auth, ambient `XAI_API_KEY`, `deployment_key`, AND a /// custom models endpoint (which normally forces the prefetch to run). #[test] #[serial] fn prefetch_env_none_when_remote_fetch_disabled_despite_credentials() { let _key = EnvGuard::set("XAI_API_KEY", "stray-env-key"); let endpoints = config::EndpointsConfig { deployment_key: Some("deploy-key".to_owned()), models_base_url: Some("https://custom.example.com".to_owned()), ..config::EndpointsConfig::default() }; assert!( resolve_prefetch_env_from_parts( Some(GrokAuth::test_default()), endpoints.clone(), false, ) .is_none(), "session auth must not re-arm the prefetch when remote_fetch is off", ); assert!( resolve_prefetch_env_from_parts(None, endpoints, false).is_none(), "API key / deployment key / custom endpoint must not re-arm it either", ); } /// Inverse sanity: with remote_fetch enabled the same credential shapes DO /// arm the prefetch, and the credential-less default still doesn't. #[test] #[serial] fn prefetch_env_resolves_when_remote_fetch_enabled() { let _unset = EnvGuard::unset("XAI_API_KEY"); let _unset_legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY"); let endpoints = config::EndpointsConfig { deployment_key: Some("deploy-key".to_owned()), ..config::EndpointsConfig::default() }; assert!(resolve_prefetch_env_from_parts(None, endpoints, true).is_some()); assert!( resolve_prefetch_env_from_parts(None, config::EndpointsConfig::default(), true) .is_none(), "no credentials and no custom endpoint must stay a no-prefetch launch", ); } /// remote_fetch=false: an online catalog refresh is a no-op — nothing is /// fetched, no real-catalog flag is set, and the static catalog keeps /// resolving. Covers `list_models`/`do_refresh` online strategies too, /// which funnel into `fetch_and_apply`/`spawn_fetch`. #[tokio::test] async fn fetch_and_apply_degrades_offline_when_remote_fetch_disabled() { let mgr = test_manager(); mgr.insert_test_entry( "static-one", ModelEntry { info: config::ModelInfo::fallback("static-one"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }, ); mgr.fetch_and_apply_inner(false).await; assert!( !mgr.has_fetched_real_catalog(), "no catalog fetch may be recorded when remote_fetch is disabled", ); assert!( mgr.models().contains_key("static-one"), "the static catalog must keep resolving", ); } // ── supported_in_api tests ────────────────────────────────────── #[test] fn default_model_skips_oauth_only_for_api_key_users() { let cfg = config::Config::default(); let mut catalog = IndexMap::new(); let mut oauth_only = ModelEntry { info: config::ModelInfo::fallback("oauth-only"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; oauth_only.info.supported_in_api = false; catalog.insert("oauth-only".to_string(), oauth_only); let public = ModelEntry { info: config::ModelInfo::fallback("public-model"), api_key: None, env_key: None, auth_provider: None, api_base_url: None, }; catalog.insert("public-model".to_string(), public); // API-key user: default should NOT be the oauth-only model let (key, _, _) = resolve_default_model(&cfg, &catalog, false); assert_ne!( key, "oauth-only", "API-key default must not be an OAuth-only model" ); assert_eq!(key, "public-model"); // OAuth user: oauth-only is valid as default (it's first in the map) let (key, _, _) = resolve_default_model(&cfg, &catalog, true); assert!( key == "oauth-only" || key == "public-model", "OAuth user should be able to use either model as default" ); } #[test] fn visible_for_auth_logic() { let mut info = config::ModelInfo::fallback("test"); // Default: visible to everyone assert!(info.visible_for_auth(true)); assert!(info.visible_for_auth(false)); // hidden = true: invisible to everyone info.hidden = true; assert!(!info.visible_for_auth(true)); assert!(!info.visible_for_auth(false)); // hidden = false, supported_in_api = false: visible to session only info.hidden = false; info.supported_in_api = false; assert!(info.visible_for_auth(true)); assert!(!info.visible_for_auth(false)); } // ── duplicate model slug re-keying (A/B experiment "auto" alias) ── fn make_entry_config(model: &str, name: Option<&str>) -> config::ModelEntryConfig { make_entry_config_with_id(None, model, name) } fn make_entry_config_with_id( id: Option<&str>, model: &str, name: Option<&str>, ) -> config::ModelEntryConfig { config::ModelEntryConfig { id: id.map(|s| s.to_owned()), model: model.to_owned(), base_url: "https://test.api/v1".to_owned(), name: name.map(|n| n.to_owned()), description: None, max_completion_tokens: None, temperature: None, top_p: None, api_key: None, env_key: None, api_backend: Default::default(), context_window: std::num::NonZeroU64::new(200_000).unwrap(), auto_compact_threshold_percent: None, system_prompt_label: None, extra_headers: IndexMap::new(), api_base_url: None, use_concise: false, agent_type: config::default_agent_type(), inference_idle_timeout_secs: None, max_retries: None, hidden: false, supported_in_api: true, auth_scheme: None, reasoning_effort: None, supports_reasoning_effort: false, reasoning_efforts: Vec::new(), supports_backend_search: false, compactions_remaining: None, compaction_at_tokens: None, show_model_fingerprint: false, stream_tool_calls: None, laziness_detector: config::LazinessDetectorPerModelConfig::default(), } } /// Experiment: two entries share the same routing slug but have distinct ids. /// Both survive, keyed by their respective ids. #[test] fn build_prefetched_map_distinct_ids_same_slug() { let entries = vec![ make_entry_config_with_id(Some("auto"), "grok-build", Some("Auto")), make_entry_config_with_id(Some("grok-build"), "grok-build", Some("Grok Build")), make_entry_config_with_id( Some("grok-composer-2.5-fast"), "grok-composer-2.5-fast", Some("Grok Fast"), ), ]; let map = build_prefetched_map(entries, None); assert_eq!(map.len(), 3, "all three entries should survive"); assert!(map.contains_key("auto")); assert!(map.contains_key("grok-build")); assert!(map.contains_key("grok-composer-2.5-fast")); assert_eq!( map["auto"].info.model, "grok-build", "auto entry should still route to grok-build" ); assert_eq!(map["grok-build"].info.model, "grok-build"); } /// No id field — falls back to model slug as key. #[test] fn build_prefetched_map_no_id_falls_back_to_slug() { let entries = vec![ make_entry_config("model-a", Some("Model A")), make_entry_config("model-b", Some("Model B")), ]; let map = build_prefetched_map(entries, None); assert_eq!(map.len(), 2); assert!(map.contains_key("model-a")); assert!(map.contains_key("model-b")); } /// Duplicate ids — second overwrites first (same as duplicate slugs before). #[test] fn build_prefetched_map_duplicate_id_overwrites() { let entries = vec![ make_entry_config_with_id(Some("grok-build"), "grok-build", Some("First")), make_entry_config_with_id(Some("grok-build"), "grok-build", Some("Second")), ]; let map = build_prefetched_map(entries, None); assert_eq!(map.len(), 1, "duplicate id: second overwrites first"); assert_eq!(map["grok-build"].info.name.as_deref(), Some("Second")); } /// Regression: resolve_default_model must match by id before scanning /// by model slug, otherwise entries sharing a slug resolve to whichever /// appears first in the catalog. #[test] fn resolve_default_model_prefers_id_over_model_slug() { let mut catalog: IndexMap = IndexMap::new(); catalog.insert( "auto-grok-build".to_string(), make_model_entry("grok-build"), ); catalog.insert("grok-build".to_string(), make_model_entry("grok-build")); let mut cfg = config::Config::default(); cfg.models.default = Some("grok-build".to_string()); let (key, _, _) = resolve_default_model(&cfg, &catalog, true); assert_eq!(key, "grok-build", "must match id, not first slug hit"); } /// No id field — falls back to slug as key. #[test] fn build_prefetched_map_none_id_falls_back_to_slug() { let entries = vec![make_entry_config_with_id( None, "grok-build", Some("Grok Build"), )]; let map = build_prefetched_map(entries, None); assert_eq!(map.len(), 1); assert!(map.contains_key("grok-build")); } // ── persisted model id → catalog key (session resume) ───────────── #[test] fn resolve_catalog_key_maps_routing_slug_to_config_key() { let mut models = IndexMap::new(); models.insert( "enterprise-grok-build".to_string(), make_model_entry("grok-4.5"), ); models.insert("grok-4.3".to_string(), make_model_entry("grok-4.3")); let persisted = acp::ModelId::new("grok-4.5"); let key = resolve_catalog_key(&models, &persisted).expect("slug must resolve"); assert_eq!(key.0.as_ref(), "enterprise-grok-build"); } #[test] fn resolve_catalog_key_prefers_exact_key_match() { let mut models = IndexMap::new(); models.insert("grok-4.5".to_string(), make_model_entry("grok-4.5")); let persisted = acp::ModelId::new("grok-4.5"); let key = resolve_catalog_key(&models, &persisted).expect("exact key must resolve"); assert_eq!(key.0.as_ref(), "grok-4.5"); } #[test] fn resolve_catalog_key_last_slug_match_wins() { let mut models = IndexMap::new(); models.insert( "default-grok-build".to_string(), make_model_entry("grok-4.5"), ); models.insert("user-grok-build".to_string(), make_model_entry("grok-4.5")); let persisted = acp::ModelId::new("grok-4.5"); let key = resolve_catalog_key(&models, &persisted).expect("slug must resolve"); assert_eq!(key.0.as_ref(), "user-grok-build"); } #[test] fn selectable_catalog_key_for_persisted_none_when_resolved_not_available() { let mut models = IndexMap::new(); models.insert( "enterprise-grok-build".to_string(), make_model_entry("grok-4.5"), ); let available: IndexMap<_, _> = IndexMap::new(); let persisted = acp::ModelId::new("grok-4.5"); assert!(selectable_catalog_key_for_persisted(&models, &available, &persisted).is_none()); } #[test] fn selectable_prefers_available_identity_over_non_selectable_exact_key() { let mut models = IndexMap::new(); models.insert("grok-build".to_string(), make_model_entry("grok-build")); models.insert( "enterprise-grok-build".to_string(), make_model_entry("grok-build"), ); models.insert("grok-4.3".to_string(), make_model_entry("grok-4.3")); let available = test_available_keys(&["enterprise-grok-build", "grok-4.3"]); let persisted = acp::ModelId::new("grok-build"); assert_eq!( resolve_catalog_key(&models, &persisted) .expect("exact key exists") .0 .as_ref(), "grok-build" ); let key = selectable_catalog_key_for_persisted(&models, &available, &persisted) .expect("must resolve to selectable section"); assert_eq!(key.0.as_ref(), "enterprise-grok-build"); } #[test] fn selectable_matches_routing_slug_when_no_exact_key() { let mut models = IndexMap::new(); models.insert( "enterprise-grok-build".to_string(), make_model_entry("grok-build"), ); models.insert("grok-4.3".to_string(), make_model_entry("grok-4.3")); let available = test_available_keys(&["enterprise-grok-build", "grok-4.3"]); let persisted = acp::ModelId::new("grok-build"); let key = selectable_catalog_key_for_persisted(&models, &available, &persisted) .expect("slug must resolve to selectable key"); assert_eq!(key.0.as_ref(), "enterprise-grok-build"); } /// A persisted *selectable* catalog key binds to itself even when a later /// selectable section's routing slug equals that key (exact key wins). #[test] fn selectable_prefers_exact_key_over_later_slug_match() { let mut models = IndexMap::new(); models.insert("grok-build".to_string(), make_model_entry("grok-4.5")); models.insert("other".to_string(), make_model_entry("grok-build")); let available = test_available_keys(&["grok-build", "other"]); let persisted = acp::ModelId::new("grok-build"); let key = selectable_catalog_key_for_persisted(&models, &available, &persisted) .expect("exact selectable key must win"); assert_eq!(key.0.as_ref(), "grok-build"); } fn test_available_keys(keys: &[&str]) -> IndexMap { keys.iter() .map(|k| { let id = acp::ModelId::new(*k); (id.clone(), acp::ModelInfo::new(id, (*k).to_string())) }) .collect() } }