use galaxy_core::ui::icons::Icon; use galaxy_core::user_preferences::GetUserPreferences; use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity}; use parking_lot::FairMutex; use serde::{de, Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, sync::{Arc, OnceLock}, }; use crate::{ auth::{ auth_manager::{AuthManager, AuthManagerEvent}, AuthStateProvider, }, network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}, report_error, server::server_api::ServerApiProvider, settings::ai::{AISettings, AISettingsChangedEvent, BedrockModelConfig, OpenAIModelConfig}, workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}, }; use settings::Setting; use super::execution_profiles::profiles::AIExecutionProfilesModel; #[cfg(not(target_family = "wasm"))] use super::bedrock::models::get_effective_models; 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()); match provider { LLMProvider::OpenAI => api_keys.is_some_and(|keys| keys.openai.is_some()), LLMProvider::Anthropic => api_keys.is_some_and(|keys| keys.anthropic.is_some()), LLMProvider::Google => api_keys.is_some_and(|keys| keys.google.is_some()), LLMProvider::Bedrock => true, LLMProvider::LiteLLM => true, _ => false, } } /// Key for cached LLM metadata in user preferences. /// /// Note: this key used to store a single [`AvailableLLMs`] /// but was migrated to store a full [`ModelsByFeature`]. pub const MODELS_BY_FEATURE_CACHE_KEY: &str = "AvailableLLMs"; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct LLMUsageMetadata { pub request_multiplier: usize, pub credit_multiplier: Option, } #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DisableReason { AdminDisabled, OutOfRequests, ProviderOutage, RequiresUpgrade, Unavailable, } impl DisableReason { /// Returns a user-facing tooltip explaining why the model is disabled. pub fn tooltip_text(&self) -> &'static str { match self { DisableReason::AdminDisabled => "This model has been disabled by your team admin.", DisableReason::OutOfRequests => "Please upgrade your plan to make more requests.", DisableReason::ProviderOutage => { "This model is temporarily unavailable due to a provider outage." } DisableReason::RequiresUpgrade => "Please upgrade your plan to access this model.", DisableReason::Unavailable => "This model is unavailable.", } } } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct LLMSpec { pub cost: f32, pub quality: f32, pub speed: f32, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum LLMProvider { OpenAI, Anthropic, Google, Xai, Bedrock, /// Models served through an OpenAI-compatible proxy (e.g. LiteLLM). LiteLLM, Unknown, } impl LLMProvider { /// Maps an LLMProvider to its corresponding icon. pub fn icon(&self) -> Option { match self { LLMProvider::OpenAI => Some(Icon::OpenAILogo), LLMProvider::Anthropic => Some(Icon::ClaudeLogo), LLMProvider::Google => Some(Icon::GeminiLogo), LLMProvider::Bedrock => Some(Icon::BedrockLogo), LLMProvider::LiteLLM => Some(Icon::OpenAILogo), LLMProvider::Xai => None, LLMProvider::Unknown => None, } } } /// The host where an LLM can be routed to. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum LLMModelHost { DirectApi, AwsBedrock, #[serde(other)] Unknown, } /// Configuration for routing an LLM to a specific host. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RoutingHostConfig { pub enabled: bool, pub model_routing_host: LLMModelHost, } /// Metadata about an LLM. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct LLMInfo { pub display_name: String, pub base_model_name: String, pub id: LLMId, pub reasoning_level: Option, pub usage_metadata: LLMUsageMetadata, pub description: Option, pub disable_reason: Option, pub vision_supported: bool, pub spec: Option, pub provider: LLMProvider, pub host_configs: HashMap, pub discount_percentage: Option, } impl<'de> Deserialize<'de> for LLMInfo { fn deserialize(deserializer: D) -> Result where D: de::Deserializer<'de>, { /// Helper type that can deserialize host_configs from either: /// - A Vec (wire format from server) /// - A HashMap (cached format after commit a8a82421c3) #[derive(Deserialize)] #[serde(untagged)] enum HostConfigsWire { Vec(Vec), Map(HashMap), } impl Default for HostConfigsWire { fn default() -> Self { HostConfigsWire::Vec(Vec::new()) } } #[derive(Deserialize)] struct WireLLMInfo { display_name: String, #[serde(default)] base_model_name: Option, id: LLMId, #[serde(default)] reasoning_level: Option, usage_metadata: LLMUsageMetadata, #[serde(default)] description: Option, #[serde(default)] disable_reason: Option, #[serde(default)] vision_supported: bool, #[serde(default)] spec: Option, provider: LLMProvider, #[serde(default)] host_configs: HostConfigsWire, #[serde(default)] discount_percentage: Option, } let wire = WireLLMInfo::deserialize(deserializer)?; let host_configs = match wire.host_configs { HostConfigsWire::Map(map) => map, HostConfigsWire::Vec(vec) => { let mut map = HashMap::new(); for config in vec { let host = config.model_routing_host.clone(); if map.insert(host.clone(), config).is_some() { log::warn!( "Duplicate LLMModelHost entry for {:?}, using latest value", host ); } } map } }; Ok(Self { base_model_name: wire .base_model_name .unwrap_or_else(|| wire.display_name.clone()), vision_supported: wire.vision_supported, provider: wire.provider, display_name: wire.display_name, id: wire.id, reasoning_level: wire.reasoning_level, usage_metadata: wire.usage_metadata, description: wire.description, disable_reason: wire.disable_reason, spec: wire.spec, host_configs, discount_percentage: wire.discount_percentage, }) } } /// Deduplicates a list of LLMInfo choices by base_model_name and returns an alphabetically sorted /// list of display names. pub fn dedupe_model_display_names<'a>( choices: impl IntoIterator, ) -> Vec { let names: HashSet = choices .into_iter() .map(|choice| choice.base_model_name.clone()) .collect(); let mut sorted: Vec = names.into_iter().collect(); sorted.sort(); sorted } impl LLMInfo { /// Returns the display name for the LLM, to be used in the LLM selector menu. pub fn menu_display_name(&self) -> String { // Base label includes optional description in parentheses match &self.description { // This is a temporary implementation that won't scale well for longer // descriptions. We should implement a better approach for displaying // model descriptions, maybe through subtext. Some(desc) => format!("{} ({})", self.display_name, desc), None => self.display_name.clone(), } } /// Returns the given model's base name. /// For non-reasoning models, this is the same as the display name. /// E.g. gpt-5.1 (low reasoning) -> gpt-5.1 pub fn base_model_name(&self) -> &str { &self.base_model_name } /// Returns true if this model has a reasoning level configured. pub fn has_reasoning_level(&self) -> bool { self.reasoning_level.is_some() } /// Returns the reasoning level label formatted for display. pub fn reasoning_level(&self) -> Option { self.reasoning_level.clone() } #[cfg(feature = "integration_tests")] fn new_for_test(llm_name: &str) -> Self { Self { display_name: llm_name.to_string(), base_model_name: llm_name.to_string(), id: llm_name.into(), reasoning_level: None, usage_metadata: LLMUsageMetadata { request_multiplier: 1, credit_multiplier: None, }, description: None, disable_reason: None, vision_supported: false, // Default to false for tests spec: None, provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, } } } /// The set of LLMs available for a feature. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AvailableLLMs { /// The Warp "default" LLM. default_id: LLMId, choices: Vec, #[serde(default)] preferred_codex_model_id: Option, } impl AvailableLLMs { /// Constructs an `AvailableLLMs` instance from the given default ID and choices. /// /// If choices is empty, returns an error. /// /// If default_id is not a valid ID present in `choices`, takes the first choice in `choices /// and uses it as the default. pub fn new>( mut default_id: LLMId, choices: impl IntoIterator, preferred_codex_model_id: Option, ) -> Result { let choices: Vec = choices.into_iter().map(Into::into).collect(); if choices.is_empty() { return Err(anyhow::anyhow!( "Tried to create AvailableLLMs with empty`choices`.", )); } else if !choices.iter().any(|info| info.id == default_id) { let fallback_default = choices .first() .ok_or_else(|| anyhow::anyhow!("Choices should not be empty"))?; log::error!( "Default LLM ID {} not present in choices, falling back to first choice {}", default_id, fallback_default.display_name ); default_id = fallback_default.id.clone(); } Ok(Self { default_id, choices: choices.into_iter().collect(), preferred_codex_model_id, }) } fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> { self.choices.iter().find(|info| info.id == *id) } fn default_llm_info(&self) -> &LLMInfo { self.info_for_id(&self.default_id) .expect("Default LLM ID must be present in choices") } #[cfg(feature = "integration_tests")] pub fn new_for_test(llm_name: &str) -> Self { Self { default_id: llm_name.into(), choices: vec![LLMInfo::new_for_test(llm_name)], preferred_codex_model_id: None, } } } /// The set of models available to the client, grouped by the feature they support. /// This is fetched from the server and cached. /// /// Currently, if a model is available for multiple features, /// it will appear denormalized in each of the feature's /// [`AvailableLLMs`]. While this denormalization doesn't add much value today, /// it eventually lets us add feature-specific properties to an [`LLMInfo`]. /// /// NOTE: This used to include a `planning` field; this was removed after planning via subagent was /// deprecated. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ModelsByFeature { pub agent_mode: AvailableLLMs, pub coding: AvailableLLMs, /// The set of LLMs available for CLI agent. /// This field is optional during deserialization, as older clients might not have this field. #[serde(default)] pub cli_agent: Option, /// The set of LLMs available for computer use agent. /// This field is optional during deserialization, as older clients might not have this field. #[serde(default)] pub computer_use: Option, } impl ModelsByFeature { /// Returns the info about the LLM identified by `id`, if we have it. /// /// For models that are available across multiple features, /// any one of the metadata will be returned. fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> { self.agent_mode.info_for_id(id) } } /// Returns the default AvailableLLMs for computer use. /// Used both in `ModelsByFeature::default()` and as a fallback in `get_computer_use_available()`. fn default_computer_use_llms() -> AvailableLLMs { AvailableLLMs { default_id: "computer-use-agent-auto".to_owned().into(), choices: vec![LLMInfo { display_name: "auto".to_owned(), base_model_name: "auto".to_owned(), id: "computer-use-agent-auto".to_owned().into(), reasoning_level: None, usage_metadata: LLMUsageMetadata { request_multiplier: 1, credit_multiplier: None, }, description: None, disable_reason: None, vision_supported: true, spec: None, provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, }], preferred_codex_model_id: None, } } impl Default for ModelsByFeature { fn default() -> Self { Self { agent_mode: AvailableLLMs { default_id: "anthropic.claude-opus-4-6[1m]".to_owned().into(), choices: vec![LLMInfo { display_name: "Claude Opus 4.6".to_owned(), base_model_name: "Claude Opus 4.6".to_owned(), id: "anthropic.claude-opus-4-6[1m]".to_owned().into(), reasoning_level: None, usage_metadata: LLMUsageMetadata { request_multiplier: 1, credit_multiplier: None, }, description: None, disable_reason: None, vision_supported: true, spec: None, provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, }], preferred_codex_model_id: None, }, coding: AvailableLLMs { default_id: "anthropic.claude-sonnet-4-6[1m]".to_owned().into(), choices: vec![LLMInfo { display_name: "Claude Sonnet 4.6".to_owned(), base_model_name: "Claude Sonnet 4.6".to_owned(), id: "anthropic.claude-sonnet-4-6[1m]".to_owned().into(), reasoning_level: None, usage_metadata: LLMUsageMetadata { request_multiplier: 1, credit_multiplier: None, }, description: None, disable_reason: None, vision_supported: true, spec: None, provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, }], preferred_codex_model_id: None, }, cli_agent: Some(AvailableLLMs { default_id: "anthropic.claude-haiku-4-5-20251001-v1:0".to_owned().into(), choices: vec![LLMInfo { display_name: "Claude Haiku 4.5".to_owned(), base_model_name: "Claude Haiku 4.5".to_owned(), id: "anthropic.claude-haiku-4-5-20251001-v1:0".to_owned().into(), reasoning_level: None, usage_metadata: LLMUsageMetadata { request_multiplier: 1, credit_multiplier: None, }, description: None, disable_reason: None, vision_supported: false, spec: None, provider: LLMProvider::Unknown, host_configs: HashMap::new(), discount_percentage: None, }], preferred_codex_model_id: None, }), computer_use: Some(default_computer_use_llms()), } } } enum UpdatePopupVisibilityState { WaitingToBeShown, Visible(EntityId), Hidden, } struct AvailableLLMsUpdate { new_choices: Vec, popup_visibility_state: Arc>, } /// Singleton model holding user/workspace LLM preferences, including the set of LLMs available for /// use as well as the user's preferred LLM for Agent Mode. pub struct LLMPreferences { models_by_feature: ModelsByFeature, last_update: Option, base_llm_for_terminal_view: HashMap, } impl LLMPreferences { pub fn new(ctx: &mut ModelContext) -> Self { let models_by_feature = get_cached_models(ctx).unwrap_or_default(); ctx.subscribe_to_model(&NetworkStatus::handle(ctx), |me, event, ctx| { if let NetworkStatusEvent::NetworkStatusChanged { new_status: NetworkStatusKind::Online, } = event { me.refresh_authed_models(ctx); } }); // TODO: Instead of querying this ad-hoc upon a successful log in, we should add the // available LLMs query to the general workspace metadata query which is polled // and hooked up to workspace changes. For that to work, each user would need to // have a personal workspace. This is a stop-gap. ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, event, ctx| { if let AuthManagerEvent::AuthComplete = event { me.refresh_authed_models(ctx); } }); ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, event, ctx| { if let UserWorkspacesEvent::TeamsChanged = event { me.refresh_authed_models(ctx); } }); #[cfg(not(target_family = "wasm"))] ctx.subscribe_to_model(&AISettings::handle(ctx), |me, event, ctx| { if matches!( event, AISettingsChangedEvent::BedrockEnabled { .. } | AISettingsChangedEvent::BedrockModels { .. } | AISettingsChangedEvent::BedrockCrossRegionInference { .. } | AISettingsChangedEvent::BedrockRegion { .. } ) { me.inject_bedrock_models(ctx); ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); } if matches!( event, AISettingsChangedEvent::OpenAIEnabled { .. } | AISettingsChangedEvent::OpenAIModels { .. } | AISettingsChangedEvent::OpenAIBaseUrl { .. } ) { me.inject_openai_models(ctx); ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); } }); let base_llm_for_terminal_view = HashMap::new(); let mut me = Self { models_by_feature, last_update: None, base_llm_for_terminal_view, }; // In agent mode eval builds, eagerly kick off a fetch of the model list from the server // so that it's available by the time test steps like `set_preferred_agent_mode_llm` run. // In production, this is handled reactively (on auth complete, network online, etc.) // to avoid duplicate requests at startup. #[cfg(feature = "agent_mode_evals")] me.refresh_available_models(ctx); #[cfg(not(target_family = "wasm"))] { Self::ensure_default_models_in_settings(ctx); me.inject_bedrock_models(ctx); me.inject_openai_models(ctx); } me } #[cfg(not(target_family = "wasm"))] fn ensure_default_models_in_settings(ctx: &mut ModelContext) { use crate::ai::bedrock::models::DEFAULT_BEDROCK_MODELS; let settings = AISettings::as_ref(ctx); let mut current_models: Vec = settings.bedrock_models.value().clone(); let existing_ids: std::collections::HashSet = current_models.iter().map(|m| m.model_id.clone()).collect(); let mut added = false; for default in DEFAULT_BEDROCK_MODELS { if !existing_ids.contains(default.model_id as &str) { current_models.push(BedrockModelConfig { model_id: default.model_id.to_string(), display_name: default.display_name.to_string(), vision_supported: default.vision_supported, context_size: default.context_size, }); added = true; } } if added { log::info!( "[bedrock] Added missing default models to settings — now {} total", current_models.len() ); AISettings::handle(ctx).update(ctx, |settings, ctx| { let _ = settings.bedrock_models.set_value(current_models, ctx); }); } } #[cfg(not(target_family = "wasm"))] fn inject_bedrock_models(&mut self, ctx: &AppContext) { self.models_by_feature .agent_mode .choices .retain(|m| m.provider != LLMProvider::Bedrock); self.models_by_feature .coding .choices .retain(|m| m.provider != LLMProvider::Bedrock); if let Some(ref mut cli) = self.models_by_feature.cli_agent { cli.choices.retain(|m| m.provider != LLMProvider::Bedrock); } let settings = AISettings::as_ref(ctx); if !*settings.bedrock_enabled.value() { return; } let user_models: Vec = settings.bedrock_models.value().clone(); let region = settings.bedrock_region.value().clone(); let cross_region = *settings.bedrock_cross_region_inference.value(); // Check if user wants only 1-hour cache models use crate::ai::bedrock::external_config::ExternalBedrockConfig; let external_config = ExternalBedrockConfig::load(); let require_1h_cache = external_config.enable_prompt_caching_1h; let mut effective = get_effective_models(&user_models); // Filter out models that don't support 1-hour caching if required if require_1h_cache { effective.retain(|model| { // 1-hour caching is supported by Claude 4.5+ models // Opus 4.5+, Sonnet 4.5+, Haiku 4.5+ let supports_1h = model.model_id.contains("4-5") || model.model_id.contains("4.5") || model.model_id.contains("-4-6") // Opus/Sonnet 4.6+ also support 1h || model.model_id.contains("4.6") || model.model_id.contains("-4-7") || model.model_id.contains("4.7") || model.model_id.contains("-4-8") || model.model_id.contains("4.8"); if !supports_1h { log::info!( "[bedrock] Filtering out model {} - does not support 1-hour cache (ENABLE_PROMPT_CACHING_1H=1)", model.model_id ); } supports_1h }); if effective.is_empty() { log::warn!("[bedrock] No models left after filtering for 1-hour cache support!"); } } let effective = effective; for model in effective { let model_id = if cross_region && !region.is_empty() { super::bedrock::models::apply_cross_region_prefix(&model.model_id, ®ion) } else { model.model_id.clone() }; let llm_info = LLMInfo { id: LLMId::from(model_id.as_str()), display_name: model.display_name.clone(), base_model_name: model.display_name.clone(), reasoning_level: None, usage_metadata: LLMUsageMetadata { request_multiplier: 1, credit_multiplier: None, }, description: Some("AWS Bedrock".to_string()), disable_reason: None, vision_supported: model.vision_supported, spec: None, provider: LLMProvider::Bedrock, host_configs: HashMap::from([( LLMModelHost::AwsBedrock, RoutingHostConfig { enabled: true, model_routing_host: LLMModelHost::AwsBedrock, }, )]), discount_percentage: None, }; self.models_by_feature .agent_mode .choices .push(llm_info.clone()); self.models_by_feature.coding.choices.push(llm_info.clone()); if let Some(ref mut cli) = self.models_by_feature.cli_agent { cli.choices.push(llm_info); } } // Remove any placeholder/auto-routing entries now that real Bedrock models are available. self.models_by_feature .agent_mode .choices .retain(|m| m.provider != LLMProvider::Unknown); self.models_by_feature .coding .choices .retain(|m| m.provider != LLMProvider::Unknown); if let Some(ref mut cli) = self.models_by_feature.cli_agent { cli.choices.retain(|m| m.provider != LLMProvider::Unknown); } // Default agent mode to ANTHROPIC_MODEL from external config if set, // otherwise Claude Opus 4.6, falling back to the first available model. // Note: external_config already loaded above for filtering let external_config_for_default = ExternalBedrockConfig::load(); if let Some(id) = external_config_for_default .anthropic_model .as_ref() .and_then(|model_id| { // Match by model_id (with or without cross-region prefix) self.models_by_feature .agent_mode .choices .iter() .find(|m| { m.id.as_str() == model_id || m.id.as_str().ends_with(model_id) || model_id.ends_with(m.id.as_str()) }) .map(|m| { log::info!( "[bedrock] Setting default agent mode model from ANTHROPIC_MODEL: {} -> {}", model_id, m.display_name ); m.id.clone() }) }) .or_else(|| { self.models_by_feature .agent_mode .choices .iter() .find(|m| m.display_name.contains("Opus 4.6")) .or_else(|| self.models_by_feature.agent_mode.choices.first()) .map(|m| m.id.clone()) }) { self.models_by_feature.agent_mode.default_id = id; } // Default coding to Claude Sonnet 4.6, falling back to the first available model. if let Some(id) = self .models_by_feature .coding .choices .iter() .find(|m| m.display_name.contains("Sonnet 4.6")) .or_else(|| self.models_by_feature.coding.choices.first()) .map(|m| m.id.clone()) { self.models_by_feature.coding.default_id = id; } // Default CLI agent to the first available model. if let Some(ref mut cli) = self.models_by_feature.cli_agent { if let Some(id) = cli.choices.first().map(|m| m.id.clone()) { cli.default_id = id; } } } /// Injects models from the OpenAI-compatible (LiteLLM) provider into the available model lists. #[cfg(not(target_family = "wasm"))] fn inject_openai_models(&mut self, ctx: &AppContext) { // Remove any previously injected LiteLLM models self.models_by_feature .agent_mode .choices .retain(|m| m.provider != LLMProvider::LiteLLM); self.models_by_feature .coding .choices .retain(|m| m.provider != LLMProvider::LiteLLM); if let Some(ref mut cli) = self.models_by_feature.cli_agent { cli.choices.retain(|m| m.provider != LLMProvider::LiteLLM); } let settings = AISettings::as_ref(ctx); if !*settings.openai_enabled.value() { return; } let user_models: Vec = settings.openai_models.value().clone(); if user_models.is_empty() { return; } let base_url = settings.openai_base_url.value().clone(); let description_label = if base_url.contains("localhost") || base_url.contains("127.0.0.1") { "LiteLLM (local)".to_string() } else { "LiteLLM".to_string() }; for model in &user_models { let llm_info = LLMInfo { id: LLMId::from(model.model_id.as_str()), display_name: model.display_name.clone(), base_model_name: model.display_name.clone(), reasoning_level: None, usage_metadata: LLMUsageMetadata { request_multiplier: 1, credit_multiplier: None, }, description: Some(description_label.clone()), disable_reason: None, vision_supported: model.vision_supported, spec: None, provider: LLMProvider::LiteLLM, host_configs: HashMap::from([( LLMModelHost::DirectApi, RoutingHostConfig { enabled: true, model_routing_host: LLMModelHost::DirectApi, }, )]), discount_percentage: None, }; self.models_by_feature .agent_mode .choices .push(llm_info.clone()); self.models_by_feature.coding.choices.push(llm_info.clone()); if let Some(ref mut cli) = self.models_by_feature.cli_agent { cli.choices.push(llm_info); } } log::info!( "[openai/litellm] Injected {} model(s) into available choices", user_models.len() ); } /// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request. pub fn get_active_base_model<'a>( &'a self, app: &'a AppContext, terminal_view_id: Option, ) -> &'a LLMInfo { self.get_preferred_base_model(app, terminal_view_id) } /// Returns `LLMInfo` for the currently selected LLM to be used for Agent Mode. fn get_preferred_base_model( &self, app: &AppContext, terminal_view_id: Option, ) -> &LLMInfo { if let Some(terminal_view_id) = terminal_view_id { let raw_override = self.base_llm_for_terminal_view.get(&terminal_view_id); if let Some(llm_id) = raw_override { if let Some(llm_info) = self.models_by_feature.agent_mode.info_for_id(llm_id) { return llm_info; } } } let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); profile .data() .base_model .clone() .and_then(|id| self.models_by_feature.agent_mode.info_for_id(&id)) .unwrap_or_else(|| self.models_by_feature.agent_mode.default_llm_info()) } pub fn get_active_coding_model<'a>( &'a self, app: &'a AppContext, terminal_view_id: Option, ) -> &'a LLMInfo { self.get_preferred_coding_model(app, terminal_view_id) } /// Returns `LLMInfo` for user's preferred coding model. fn get_preferred_coding_model( &self, app: &AppContext, terminal_view_id: Option, ) -> &LLMInfo { let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); profile .data() .coding_model .clone() .and_then(|id| self.models_by_feature.coding.info_for_id(&id)) .unwrap_or_else(|| self.models_by_feature.coding.default_llm_info()) } /// Returns the set of LLMs available for Agent Mode use. pub fn get_base_llm_choices_for_agent_mode(&self) -> impl Iterator { // Don't show admin-disabled models in the dropdown self.models_by_feature .agent_mode .choices .iter() .filter(|llm| !matches!(llm.disable_reason, Some(DisableReason::AdminDisabled))) } /// Returns the set of LLMs available for coding. pub fn get_coding_llm_choices(&self) -> impl Iterator { // Don't show admin-disabled models in the dropdown self.models_by_feature .coding .choices .iter() .filter(|llm| !matches!(llm.disable_reason, Some(DisableReason::AdminDisabled))) } /// Returns the set of LLMs available for CLI agent. pub fn get_cli_agent_llm_choices(&self) -> impl Iterator { self.get_cli_agent_available().choices.iter() } /// Returns the `LLMInfo` for the CLI agent model. pub fn get_active_cli_agent_model<'a>( &'a self, app: &'a AppContext, terminal_view_id: Option, ) -> &'a LLMInfo { let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); let available = self.get_cli_agent_available(); profile .data() .cli_agent_model .clone() .and_then(|id| available.info_for_id(&id)) .unwrap_or_else(|| available.default_llm_info()) } /// Returns the default CLI agent model as a fallback. pub fn get_default_cli_agent_model(&self) -> &LLMInfo { self.get_cli_agent_available().default_llm_info() } /// Helper to get the AvailableLLMs for cli_agent, falling back to agent_mode. fn get_cli_agent_available(&self) -> &AvailableLLMs { self.models_by_feature .cli_agent .as_ref() .unwrap_or(&self.models_by_feature.agent_mode) } /// Returns the set of LLMs available for computer use agent. pub fn get_computer_use_llm_choices(&self) -> impl Iterator { self.get_computer_use_available().choices.iter() } /// Returns the `LLMInfo` for the computer use agent model. pub fn get_active_computer_use_model<'a>( &'a self, app: &'a AppContext, terminal_view_id: Option, ) -> &'a LLMInfo { let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); let available = self.get_computer_use_available(); profile .data() .computer_use_model .clone() .and_then(|id| available.info_for_id(&id)) .unwrap_or_else(|| available.default_llm_info()) } /// Returns the default computer use model as a fallback. pub fn get_default_computer_use_model(&self) -> &LLMInfo { self.get_computer_use_available().default_llm_info() } /// Helper to get the AvailableLLMs for computer_use. /// Falls back to a computer-use-specific default if None. fn get_computer_use_available(&self) -> &AvailableLLMs { static DEFAULT: OnceLock = OnceLock::new(); self.models_by_feature .computer_use .as_ref() .unwrap_or_else(|| DEFAULT.get_or_init(default_computer_use_llms)) } /// Returns metadata about an LLM, if the client knows about it. pub fn get_llm_info(&self, id: &LLMId) -> Option<&LLMInfo> { self.models_by_feature.info_for_id(id) } /// Returns the default base model as a fallback. pub fn get_default_base_model(&self) -> &LLMInfo { self.models_by_feature.agent_mode.default_llm_info() } /// Returns the default coding model as a fallback. pub fn get_default_coding_model(&self) -> &LLMInfo { self.models_by_feature.coding.default_llm_info() } /// Returns the preferred Codex model, if set by the server. pub fn get_preferred_codex_model(&self) -> Option<&LLMInfo> { self.models_by_feature .agent_mode .preferred_codex_model_id .as_ref() .and_then(|id| self.models_by_feature.agent_mode.info_for_id(id)) } #[cfg(feature = "integration_tests")] pub fn is_available_agent_mode_llm(&self, id: &LLMId) -> bool { self.models_by_feature.agent_mode.info_for_id(id).is_some() } /// Creates a pane-level override for the Agent Mode LLM. pub fn update_preferred_agent_mode_llm( &mut self, preferred_llm_id: &LLMId, terminal_view_id: EntityId, ctx: &mut ModelContext, ) { let profile = AIExecutionProfilesModel::as_ref(ctx).active_profile(Some(terminal_view_id), ctx); let profile_default_model_id = profile .data() .base_model .as_ref() .and_then(|id| self.models_by_feature.agent_mode.info_for_id(id)) .unwrap_or_else(|| self.models_by_feature.agent_mode.default_llm_info()) .id .clone(); // Only remove override if we're setting to the profile's default. // Otherwise, always set the override explicitly. let changed = if preferred_llm_id == &profile_default_model_id { self.base_llm_for_terminal_view .remove(&terminal_view_id) .is_some() } else { self.base_llm_for_terminal_view .insert(terminal_view_id, preferred_llm_id.clone()); true }; if changed { self.trigger_snapshot_save(ctx); ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM); } } /// Triggers a snapshot save to persist LLM override changes. fn trigger_snapshot_save(&self, ctx: &mut ModelContext) { ctx.dispatch_global_action("workspace:save_app", ()); } pub fn update_preferred_coding_llm( &self, preferred_llm_id: &LLMId, terminal_view_id: Option, ctx: &mut ModelContext, ) { let new_value = if preferred_llm_id == &self.models_by_feature.coding.default_id { None } else { Some(preferred_llm_id.clone()) }; let mut changed = false; AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| { let profile = profiles.active_profile(terminal_view_id, ctx); if profile.data().coding_model != new_value { profiles.set_coding_model(*profile.id(), new_value, ctx); changed = true; } }); if changed { ctx.emit(LLMPreferencesEvent::UpdatedActiveCodingLLM); } } pub fn new_choices_since_last_update(&self) -> Option> { self.last_update.as_ref().map(|update| { // We don't want to display new choices if they are warp branded. let filter_choices: Vec = update .new_choices .clone() .into_iter() .filter(|choice| !choice.display_name.starts_with("lite")) .collect(); filter_choices }) } pub fn should_show_new_choices_popup(&self, view_id: EntityId) -> bool { self.last_update.as_ref().is_some_and(|update| { let popup_state = &*update.popup_visibility_state.lock(); matches!(popup_state, UpdatePopupVisibilityState::WaitingToBeShown) || matches!( popup_state, UpdatePopupVisibilityState::Visible(id) if *id == view_id) }) } pub fn mark_new_choices_popup_as_shown(&self, view_id: EntityId) { if let Some(update) = self.last_update.as_ref() { if matches!( &*update.popup_visibility_state.lock(), UpdatePopupVisibilityState::WaitingToBeShown ) { *update.popup_visibility_state.lock() = UpdatePopupVisibilityState::Visible(view_id); } } } pub fn hide_llm_popup(&self, view_id: EntityId) { if !self.should_show_new_choices_popup(view_id) { return; } let Some(last_update) = self.last_update.as_ref() else { return; }; *last_update.popup_visibility_state.lock() = UpdatePopupVisibilityState::Hidden; } /// Fetches the latest set of models from the server for the currently logged in user, and updates the model. pub fn refresh_authed_models(&self, ctx: &mut ModelContext) { // Don't try to fetch auth'd models if the user is not logged in yet. if !AuthStateProvider::as_ref(ctx).get().is_logged_in() { return; } let ai_api_client = ServerApiProvider::as_ref(ctx).get_ai_client(); ctx.spawn( async move { ai_api_client.get_feature_model_choices().await }, |me, result, ctx| match result { Ok(update) => { if update != me.models_by_feature { me.on_server_update(update, ctx); } } Err(e) => { report_error!(e.context("Failed to fetch LLMs from server")); } }, ); } /// No auth required (i.e. to populate the pre-login onboarding picker). fn refresh_public_models(&self, ctx: &mut ModelContext) { let ai_api_client = ServerApiProvider::as_ref(ctx).get_ai_client(); ctx.spawn( async move { ai_api_client.get_free_available_models(None).await }, |me, result, ctx| match result { Ok(update) => { if update != me.models_by_feature { me.on_server_update(update, ctx); } } Err(e) => { report_error!(e.context("Failed to fetch free-tier LLMs from server")); } }, ); } pub fn refresh_available_models(&self, ctx: &mut ModelContext) { if AuthStateProvider::as_ref(ctx).get().is_logged_in() { self.refresh_authed_models(ctx); } else { self.refresh_public_models(ctx); } } pub fn update_feature_model_choices( &mut self, choices_result: Result, ctx: &mut ModelContext, ) { if let Ok(choices) = choices_result { self.on_server_update(choices, ctx); } } fn on_server_update(&mut self, update: ModelsByFeature, ctx: &mut ModelContext) { let has_existing_persisted_config = get_cached_models(ctx).is_some(); let old = std::mem::replace(&mut self.models_by_feature, update); match serde_json::to_string(&self.models_by_feature) { Ok(serialized_update) => { if let Err(e) = ctx .private_user_preferences() .write_value(MODELS_BY_FEATURE_CACHE_KEY, serialized_update) { log::error!("Failed to cache LLMs: {e}"); } } Err(e) => { log::error!("Failed to serialize LLMs for cache: {e}"); } } #[cfg(not(target_family = "wasm"))] self.inject_bedrock_models(ctx); // Clear any model selections where the model is no longer supported. 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) { if let Some(preferred_llm_id) = &profile.data().base_model { if self .models_by_feature .agent_mode .info_for_id(preferred_llm_id) .is_none() { profiles.set_base_model(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); } } } } }); let new_choices = get_new_agent_mode_choices(&old.agent_mode, &self.models_by_feature.agent_mode); if !new_choices.is_empty() { self.last_update = Some(AvailableLLMsUpdate { new_choices, // We shouldn't show the update for the initial LLM config creation. popup_visibility_state: Arc::new(FairMutex::new( if has_existing_persisted_config { UpdatePopupVisibilityState::WaitingToBeShown } else { UpdatePopupVisibilityState::Hidden }, )), }); } ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); } pub fn vision_supported(&self, app: &AppContext, terminal_view_id: Option) -> bool { self.get_active_base_model(app, terminal_view_id) .vision_supported } pub fn get_base_llm_override(&self, terminal_view_id: EntityId) -> Option { if let Some(override_str) = self .base_llm_for_terminal_view .get(&terminal_view_id) .and_then(|llm_id| serde_json::to_string(llm_id).ok()) { return Some(override_str); } log::debug!("LLM override not found in memory for terminal view: {terminal_view_id:?}"); None } /// Removes the LLM override for a terminal view. /// This ensures that the new profile's default model is used. pub fn remove_llm_override( &mut self, terminal_view_id: EntityId, ctx: &mut ModelContext, ) { let old = self.base_llm_for_terminal_view.remove(&terminal_view_id); if old.is_some() { self.trigger_snapshot_save(ctx); ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM); } } } #[derive(Clone, Debug)] pub enum LLMPreferencesEvent { UpdatedAvailableLLMs, UpdatedActiveAgentModeLLM, UpdatedActiveCodingLLM, } impl Entity for LLMPreferences { type Event = LLMPreferencesEvent; } impl SingletonEntity for LLMPreferences {} fn get_new_agent_mode_choices( old_config: &AvailableLLMs, new_config: &AvailableLLMs, ) -> Vec { let old_ids: HashSet<_> = old_config.choices.iter().map(|info| &info.id).collect(); new_config .choices .iter() .filter(|info| !old_ids.contains(&info.id)) .cloned() .collect() } /// Gets the last cached LLM metadata. fn get_cached_models(app: &mut AppContext) -> Option { let value = app .private_user_preferences() .read_value(MODELS_BY_FEATURE_CACHE_KEY) .ok() .flatten()?; // Try to deserialize to the [`ModelsByFeature`] type. match serde_json::from_str::(value.as_str()) { Ok(config) => Some(config), Err(e1) => { // If that fails, try to deserialize directly to [`AvailableLLMs`]. // Before we had model choice by feature, all available LLMs were solely // for Agent Mode. match serde_json::from_str::(value.as_str()) { Ok(config) => Some(ModelsByFeature { agent_mode: config, ..Default::default() }), Err(e2) => { log::warn!("Failed to deserialize cached LLMs: {e1}\n{e2}"); None } } } } } #[cfg(test)] #[path = "llms_tests.rs"] mod tests;