grok-build-upstream-mirror/crates/codegen/xai-grok-shell/src/agent/models.rs
grokkybara[bot] 47348d13ec Synced from monorepo
Synced from monorepo

Changes:
- Report invalid MCP server config instead of failing startup
- Keep completed terminal output when the gateway connection is lost
- Show a duration-only detail view for single-task task output
- Don't let a stale registry turn counter hide local sessions
- Raise the file-descriptor soft limit on Linux and log effective limits at startup
- Stop aborting when HTTP client construction fails
- Make session thread and runtime spawn failures recoverable
- Fix main-prompt paste parity in the question freeform input
- Fire SessionEnd hooks on /exit and headless quit
- Embed the deployment-config signing public key
- Repaint paste-chip background on inline panel inputs
- Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root
- Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker
- Parse hooks from config files
- Add a remote kill-switch for managed-config signature verification
- Security: fix workspace file-reference resolution bypassing workspace filesystem confinement

Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
2026-07-25 18:44:42 +00:00

3628 lines
140 KiB
Rust

//! 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<String, ModelEntry>,
is_session_auth: bool,
) -> Option<String> {
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::<Vec<_>>();
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<Inner>,
}
struct Inner {
prefetched: RwLock<Option<IndexMap<String, ModelEntry>>>,
models: RwLock<IndexMap<String, ModelEntry>>,
current_model_id: RwLock<acp::ModelId>,
current_reasoning_effort: RwLock<Option<ReasoningEffort>>,
etag: RwLock<Option<String>>,
/// 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<bool>,
// ── Owned context for self-contained refresh ────────────────
auth_manager: Arc<AuthManager>,
cfg: RwLock<config::Config>,
fetch_auth: RwLock<ModelFetchAuth>,
gateway: RwLock<Option<xai_acp_lib::AcpAgentGatewaySender>>,
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<Vec<Arc<Notify>>>` listener
/// registry — no manual fan-out, no listener-leak risk, no
/// `unregister` API to maintain.
model_switch_watch: tokio::sync::watch::Sender<u64>,
}
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<IndexMap<String, ModelEntry>>,
models: IndexMap<String, ModelEntry>,
current_model_id: acp::ModelId,
auth_manager: Arc<AuthManager>,
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<Notify>` design).
pub fn subscribe_model_switch(&self) -> tokio::sync::watch::Receiver<u64> {
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<IndexMap<String, ModelEntry>>,
auth_manager: Arc<AuthManager>,
) -> Result<Self, String> {
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<String, ModelEntry> {
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<acp::ModelId, acp::ModelInfo> {
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<String> {
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<String>, entry: ModelEntry) {
self.inner.models.write().insert(id.into(), entry);
}
pub fn current_reasoning_effort(&self) -> Option<ReasoningEffort> {
*self.inner.current_reasoning_effort.read()
}
pub fn set_current_reasoning_effort(&self, effort: Option<ReasoningEffort>) {
*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<ReasoningEffort> {
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<ReasoningEffortOption> {
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<xai_grok_sampling_types::CompactionsRemaining> {
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<xai_grok_sampling_types::CompactionAtTokens> {
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<IndexMap<String, ModelEntry>> {
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<IndexMap<String, ModelEntry>>) {
*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<tokio::sync::Notify>) {
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<String>) {
// 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<String>, 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<IndexMap<String, ModelEntry>>,
new_etag: Option<String>,
) -> 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<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
grok_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
auth_method: Option<CacheAuthMethod>,
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
etag: Option<String>,
models: IndexMap<String, ModelEntry>,
}
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<String, ModelEntry>,
etag: Option<String>,
}
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<CacheResult> {
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<String, ModelEntry>,
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::<ModelsCache>(&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<config::ModelEntryConfig>,
api_base_url_override: Option<String>,
) -> IndexMap<String, ModelEntry> {
let mut map: IndexMap<String, ModelEntry> = 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<IndexMap<String, ModelEntry>> {
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<IndexMap<String, ModelEntry>>,
Option<crate::util::config::RemoteSettings>,
) {
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<IndexMap<String, ModelEntry>> {
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<IndexMap<String, ModelEntry>>,
pub settings: Option<crate::util::config::RemoteSettings>,
}
/// Handle for a startup prefetch thread.
pub type EarlyPrefetchHandle = std::thread::JoinHandle<EarlyPrefetchResult>;
struct PrefetchEnv {
auth: Option<GrokAuth>,
endpoints: config::EndpointsConfig,
model_fetch_auth: ModelFetchAuth,
}
fn resolve_prefetch_env_with_auth(auth: Option<GrokAuth>) -> Option<PrefetchEnv> {
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<GrokAuth>,
endpoints: config::EndpointsConfig,
remote_fetch_enabled: bool,
) -> Option<PrefetchEnv> {
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<GrokComConfig>) -> Option<PrefetchEnv> {
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<GrokAuth>) -> Option<EarlyPrefetchHandle> {
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<GrokComConfig>) -> Option<EarlyPrefetchHandle> {
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<GrokComConfig>,
) -> Option<EarlyPrefetchHandle> {
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<String, ModelEntry>,
id: &acp::ModelId,
) -> Option<acp::ModelId> {
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<String, ModelEntry>,
available: &IndexMap<acp::ModelId, acp::ModelInfo>,
id: &acp::ModelId,
) -> Option<acp::ModelId> {
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<String>,
new_preferred: &Option<String>,
campaign_defaults: &std::collections::HashSet<String>,
) -> 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<String, ModelEntry>,
is_session_auth: bool,
) -> (String, ModelEntry, config::ConfigSource) {
let visible: IndexMap<String, ModelEntry> = 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<String, ModelEntry>,
is_session_auth: bool,
) -> IndexMap<acp::ModelId, acp::ModelInfo> {
let visible: IndexMap<String, ModelEntry> = 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<String>>) -> Result<Option<Self>, Vec<String>> {
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<String, ModelEntry>>,
) -> IndexMap<String, ModelEntry> {
let mut catalog: IndexMap<String, ModelEntry> = 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<String, ModelEntry>,
) -> 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<String, ModelEntry>,
) -> 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<GrokAuth>,
fetch_auth: ModelFetchAuth,
) -> Option<IndexMap<String, ModelEntry>> {
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<String, ModelEntry> {
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<String> = ["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<String, ModelEntry> = 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<acp::ModelId, acp::ModelInfo> {
keys.iter()
.map(|k| {
let id = acp::ModelId::new(*k);
(id.clone(), acp::ModelInfo::new(id, (*k).to_string()))
})
.collect()
}
}