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