Select new default when a model is disabled (#10085)

We have some logic for selecting a new default model if the user's
current model is no longer available. However, this logic doesn't
account when a model is _disabled_, which is different from when a model
is _removed_.

This fixes our logic to account for model disablement.
This commit is contained in:
Daniel Peng
2026-05-04 15:03:04 -07:00
committed by GitHub
parent 361c267a33
commit 3abc48b77b
2 changed files with 147 additions and 61 deletions

View File

@@ -19,6 +19,8 @@ use crate::{
workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
};
use ai::api_keys::{ApiKeyManager, ApiKeyManagerEvent};
use super::execution_profiles::profiles::AIExecutionProfilesModel;
pub use ai::LLMId;
@@ -26,8 +28,6 @@ pub use ai::LLMId;
/// Checks if a user's' API key is being used for the given provider.
/// Returns `true` if BYO API key is enabled and a key exists for the provider.
pub fn is_using_api_key_for_provider(provider: &LLMProvider, app: &AppContext) -> bool {
use ai::api_keys::ApiKeyManager;
let api_keys = UserWorkspaces::as_ref(app)
.is_byo_api_key_enabled()
.then(|| ApiKeyManager::as_ref(app).keys().clone());
@@ -74,6 +74,23 @@ impl DisableReason {
DisableReason::Unavailable => "This model is unavailable.",
}
}
/// Returns `true` when this disable reason means the user cannot use the model
/// and we should clear their stored preference.
///
/// `RequiresUpgrade` is BYOK-aware: if the user has a BYO API key for the
/// model's provider (`has_byok_key = true`), the server will still accept
/// the request, so we keep the selection.
///
/// `OutOfRequests` and `ProviderOutage` are transient and expected to
/// resolve without user action, so we preserve the selection.
fn should_clear_preference(&self, has_byok_key: bool) -> bool {
match self {
DisableReason::AdminDisabled | DisableReason::Unavailable => true,
DisableReason::RequiresUpgrade => !has_byok_key,
DisableReason::OutOfRequests | DisableReason::ProviderOutage => false,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -353,6 +370,17 @@ impl AvailableLLMs {
self.choices.iter().find(|info| info.id == *id)
}
/// Returns the info for the given id only if the model is usable (present
/// and not effectively disabled for the current user).
fn usable_info_for_id(&self, id: &LLMId, app: &AppContext) -> Option<&LLMInfo> {
self.info_for_id(id).filter(|info| {
let has_byok_key = is_using_api_key_for_provider(&info.provider, app);
info.disable_reason
.as_ref()
.is_none_or(|reason| !reason.should_clear_preference(has_byok_key))
})
}
fn default_llm_info(&self) -> &LLMInfo {
self.info_for_id(&self.default_id)
.expect("Default LLM ID must be present in choices")
@@ -555,6 +583,15 @@ impl LLMPreferences {
}
});
// Re-reconcile disabled model preferences when BYOK keys change, since
// RequiresUpgrade models may become usable or unusable.
ctx.subscribe_to_model(
&ApiKeyManager::handle(ctx),
|me, _event: &ApiKeyManagerEvent, ctx| {
me.reconcile_disabled_model_preferences(ctx);
},
);
let base_llm_for_terminal_view = HashMap::new();
let me = Self {
@@ -945,65 +982,7 @@ impl LLMPreferences {
}
}
// Clear any model selections where the model is no longer supported,
// and clear orphaned context window limits for non-configurable models.
let profiles_model = AIExecutionProfilesModel::handle(ctx);
profiles_model.update(ctx, |profiles, ctx| {
for profile_id in profiles.get_all_profile_ids() {
if let Some(profile) = profiles.get_profile_by_id(profile_id, ctx) {
let profile_data = profile.data();
let preferred_base_model = profile_data.base_model.clone();
let effective_base_model_id = preferred_base_model
.as_ref()
.unwrap_or(&self.models_by_feature.agent_mode.default_id);
let effective_base_model_info = self
.models_by_feature
.agent_mode
.info_for_id(effective_base_model_id);
let effective_base_model_missing = effective_base_model_info.is_none();
let effective_base_model_is_configurable = effective_base_model_info
.is_some_and(|info| info.context_window.is_configurable);
let has_context_window_limit = profile_data.context_window_limit.is_some();
if preferred_base_model.is_some() && effective_base_model_missing {
profiles.set_base_model(profile_id, None, ctx);
}
if has_context_window_limit
&& (effective_base_model_missing || !effective_base_model_is_configurable)
{
profiles.set_context_window_limit(profile_id, None, ctx);
}
if let Some(preferred_llm_id) = &profile.data().coding_model {
if self
.models_by_feature
.coding
.info_for_id(preferred_llm_id)
.is_none()
{
profiles.set_coding_model(profile_id, None, ctx);
}
}
if let Some(preferred_llm_id) = &profile.data().cli_agent_model {
if self
.get_cli_agent_available()
.info_for_id(preferred_llm_id)
.is_none()
{
profiles.set_cli_agent_model(profile_id, None, ctx);
}
}
if let Some(preferred_llm_id) = &profile.data().computer_use_model {
if self
.get_computer_use_available()
.info_for_id(preferred_llm_id)
.is_none()
{
profiles.set_computer_use_model(profile_id, None, ctx);
}
}
}
}
});
self.reconcile_disabled_model_preferences(ctx);
let new_choices =
get_new_agent_mode_choices(&old.agent_mode, &self.models_by_feature.agent_mode);
@@ -1024,6 +1003,72 @@ impl LLMPreferences {
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
/// Clear any model selections where the model is no longer supported
/// or effectively disabled, and clear orphaned context window limits
/// for non-configurable or unusable models.
///
/// Called both when the model list is refreshed from the server and when
/// BYOK API keys change (since `RequiresUpgrade` usability is BYOK-aware).
fn reconcile_disabled_model_preferences(&self, ctx: &mut ModelContext<Self>) {
let profiles_model = AIExecutionProfilesModel::handle(ctx);
profiles_model.update(ctx, |profiles, ctx| {
for profile_id in profiles.get_all_profile_ids() {
if let Some(profile) = profiles.get_profile_by_id(profile_id, ctx) {
let profile_data = profile.data();
let preferred_base_model = profile_data.base_model.clone();
let effective_base_model_id = preferred_base_model
.as_ref()
.unwrap_or(&self.models_by_feature.agent_mode.default_id);
let effective_base_model_usable = self
.models_by_feature
.agent_mode
.usable_info_for_id(effective_base_model_id, ctx);
let effective_base_model_unusable = effective_base_model_usable.is_none();
let effective_base_model_is_configurable = effective_base_model_usable
.is_some_and(|info| info.context_window.is_configurable);
let has_context_window_limit = profile_data.context_window_limit.is_some();
if preferred_base_model.is_some() && effective_base_model_unusable {
profiles.set_base_model(profile_id, None, ctx);
}
if has_context_window_limit
&& (effective_base_model_unusable || !effective_base_model_is_configurable)
{
profiles.set_context_window_limit(profile_id, None, ctx);
}
if let Some(preferred_llm_id) = &profile.data().coding_model {
if self
.models_by_feature
.coding
.usable_info_for_id(preferred_llm_id, ctx)
.is_none()
{
profiles.set_coding_model(profile_id, None, ctx);
}
}
if let Some(preferred_llm_id) = &profile.data().cli_agent_model {
if self
.get_cli_agent_available()
.usable_info_for_id(preferred_llm_id, ctx)
.is_none()
{
profiles.set_cli_agent_model(profile_id, None, ctx);
}
}
if let Some(preferred_llm_id) = &profile.data().computer_use_model {
if self
.get_computer_use_available()
.usable_info_for_id(preferred_llm_id, ctx)
.is_none()
{
profiles.set_computer_use_model(profile_id, None, ctx);
}
}
}
}
});
}
pub fn vision_supported(&self, app: &AppContext, terminal_view_id: Option<EntityId>) -> bool {
self.get_active_base_model(app, terminal_view_id)
.vision_supported

View File

@@ -1,5 +1,46 @@
use super::*;
// -- DisableReason::should_clear_preference tests --
#[test]
fn should_clear_preference_admin_disabled() {
// AdminDisabled always clears, regardless of BYOK status.
assert!(DisableReason::AdminDisabled.should_clear_preference(false));
assert!(DisableReason::AdminDisabled.should_clear_preference(true));
}
#[test]
fn should_clear_preference_unavailable() {
assert!(DisableReason::Unavailable.should_clear_preference(false));
assert!(DisableReason::Unavailable.should_clear_preference(true));
}
#[test]
fn should_not_clear_preference_out_of_requests() {
// Transient — never clears.
assert!(!DisableReason::OutOfRequests.should_clear_preference(false));
assert!(!DisableReason::OutOfRequests.should_clear_preference(true));
}
#[test]
fn should_not_clear_preference_provider_outage() {
// Transient — never clears.
assert!(!DisableReason::ProviderOutage.should_clear_preference(false));
assert!(!DisableReason::ProviderOutage.should_clear_preference(true));
}
#[test]
fn should_clear_preference_requires_upgrade_without_byok() {
// No BYOK key → server will reject → clear.
assert!(DisableReason::RequiresUpgrade.should_clear_preference(false));
}
#[test]
fn should_not_clear_preference_requires_upgrade_with_byok() {
// BYOK key present → server allows → keep.
assert!(!DisableReason::RequiresUpgrade.should_clear_preference(true));
}
#[test]
fn llm_info_deserializes_without_base_model_name() {
let raw = r#"{