first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+647 -128
View File
@@ -1,61 +1,59 @@
use galaxy_core::ui::icons::Icon;
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, OnceLock};
use ai::api_keys::{ApiKeyManager, ApiKeyManagerEvent, CustomEndpoint, CustomEndpointModel};
pub use ai::LLMId;
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,
OpenAIProviderConfig,
},
workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
};
use settings::Setting;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::icons::Icon;
use galaxy_core::user_preferences::GetUserPreferences;
use warp_multi_agent_api as api;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError};
use super::execution_profiles::profiles::AIExecutionProfilesModel;
#[cfg(not(target_family = "wasm"))]
use super::bedrock::models::get_effective_models;
pub use ai::LLMId;
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::AuthStateProvider;
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
use crate::report_error;
use crate::server::server_api::ServerApiProvider;
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
/// 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.
/// For xAI, a connected Grok subscription counts: its OAuth access token is
/// sent like a BYO key (see `ApiKeyManager::api_keys_for_request`).
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());
if !UserWorkspaces::as_ref(app).is_byo_api_key_enabled(app) {
return false;
}
let manager = ApiKeyManager::as_ref(app);
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,
LLMProvider::OpenAI => manager.keys().openai.is_some(),
LLMProvider::Anthropic => manager.keys().anthropic.is_some(),
LLMProvider::Google => manager.keys().google.is_some(),
LLMProvider::Xai => manager.grok_tokens().is_some(),
LLMProvider::Unknown => false,
}
}
pub fn should_show_bedrock_icon_for_model(llm: &LLMInfo, app: &AppContext) -> bool {
UserWorkspaces::as_ref(app).is_aws_bedrock_credentials_enabled(app)
&& llm
.host_configs
.get(&LLMModelHost::AwsBedrock)
.is_some_and(|config| config.enabled)
}
/// 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";
const CUSTOM_ENDPOINT_USAGE_FALLBACK_LABEL: &str = "Custom endpoint";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LLMUsageMetadata {
@@ -85,6 +83,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)]
@@ -119,6 +134,17 @@ impl LLMProvider {
LLMProvider::Unknown => None,
}
}
/// Human-readable provider name for user-facing copy.
pub fn display_name(&self) -> &'static str {
match self {
LLMProvider::OpenAI => "OpenAI",
LLMProvider::Anthropic => "Anthropic",
LLMProvider::Google => "Google",
LLMProvider::Xai => "xAI",
LLMProvider::Unknown => "this provider",
}
}
}
/// The host where an LLM can be routed to.
@@ -126,6 +152,8 @@ impl LLMProvider {
pub enum LLMModelHost {
DirectApi,
AwsBedrock,
CustomEndpoint,
GeminiEnterprise,
#[serde(other)]
Unknown,
}
@@ -137,6 +165,18 @@ pub struct RoutingHostConfig {
pub model_routing_host: LLMModelHost,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LLMContextWindow {
#[serde(default)]
pub is_configurable: bool,
#[serde(default)]
pub min: u32,
#[serde(default)]
pub max: u32,
#[serde(default)]
pub default_max: u32,
}
/// Metadata about an LLM.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct LLMInfo {
@@ -152,6 +192,7 @@ pub struct LLMInfo {
pub provider: LLMProvider,
pub host_configs: HashMap<LLMModelHost, RoutingHostConfig>,
pub discount_percentage: Option<f32>,
pub context_window: LLMContextWindow,
}
impl<'de> Deserialize<'de> for LLMInfo {
@@ -197,6 +238,8 @@ impl<'de> Deserialize<'de> for LLMInfo {
host_configs: HostConfigsWire,
#[serde(default)]
discount_percentage: Option<f32>,
#[serde(default)]
context_window: LLMContextWindow,
}
let wire = WireLLMInfo::deserialize(deserializer)?;
@@ -231,6 +274,7 @@ impl<'de> Deserialize<'de> for LLMInfo {
spec: wire.spec,
host_configs,
discount_percentage: wire.discount_percentage,
context_window: wire.context_window,
})
}
}
@@ -252,6 +296,12 @@ pub fn dedupe_model_display_names<'a>(
impl LLMInfo {
/// Returns the display name for the LLM, to be used in the LLM selector menu.
pub fn menu_display_name(&self) -> String {
// Custom model routers carry a routing/source description that belongs in
// the sidecar detail panel, not inline in the chip label. Appending it
// here would produce a redundant "(Routes by … · …)" suffix.
if custom_model_routers::is_custom_router_id(self.id.as_str()) {
return self.display_name.clone();
}
// Base label includes optional description in parentheses
match &self.description {
// This is a temporary implementation that won't scale well for longer
@@ -297,6 +347,7 @@ impl LLMInfo {
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
discount_percentage: None,
context_window: LLMContextWindow::default(),
}
}
}
@@ -352,6 +403,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")
@@ -422,6 +484,7 @@ fn default_computer_use_llms() -> AvailableLLMs {
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
discount_percentage: None,
context_window: LLMContextWindow::default(),
}],
preferred_codex_model_id: None,
}
@@ -448,6 +511,7 @@ impl Default for ModelsByFeature {
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
discount_percentage: None,
context_window: LLMContextWindow::default(),
}],
preferred_codex_model_id: None,
},
@@ -469,6 +533,7 @@ impl Default for ModelsByFeature {
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
discount_percentage: None,
context_window: LLMContextWindow::default(),
}],
preferred_codex_model_id: None,
},
@@ -490,6 +555,7 @@ impl Default for ModelsByFeature {
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
discount_percentage: None,
context_window: LLMContextWindow::default(),
}],
preferred_codex_model_id: None,
}),
@@ -515,17 +581,23 @@ pub struct LLMPreferences {
models_by_feature: ModelsByFeature,
last_update: Option<AvailableLLMsUpdate>,
base_llm_for_terminal_view: HashMap<EntityId, LLMId>,
/// Maps model IDs from OpenAI-compatible providers to their client configs.
/// Used by `resolve_provider_config` to route requests to the correct endpoint.
#[cfg(not(target_family = "wasm"))]
openai_provider_routing: HashMap<String, super::openai::client::OpenAIClientConfig>,
/// Synthetic `LLMInfo` entries built from the user's `ApiKeyManager.custom_endpoints` so
/// custom models surface in the model picker and resolve through `info_for_id` lookups.
/// Each entry's `id` is the model's `config_key` (UUID), which is also what flows out to
/// `Request.Settings.custom_model_providers.providers[*].models[*].config_key`.
///
/// Rebuilt from scratch on every `ApiKeyManagerEvent::KeysUpdated`, so adds, edits, and
/// removals all immediately propagate to the picker.
custom_llms: Vec<LLMInfo>,
/// All custom model routers, including both local and cloud-backed.
custom_model_routers: Vec<CustomModelRouter>,
}
impl LLMPreferences {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let models_by_feature = get_cached_models(ctx).unwrap_or_default();
ctx.subscribe_to_model(&NetworkStatus::handle(ctx), |me, event, ctx| {
ctx.subscribe_to_model(&NetworkStatus::handle(ctx), |me, _, event, ctx| {
if let NetworkStatusEvent::NetworkStatusChanged {
new_status: NetworkStatusKind::Online,
} = event
@@ -538,52 +610,60 @@ impl LLMPreferences {
// 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| {
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| {
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| {
if let UserWorkspacesEvent::TeamsChanged = event {
me.sanitize_disabled_custom_model_preferences(ctx);
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);
// Re-reconcile disabled model preferences when BYOK keys change, since
// RequiresUpgrade models may become usable or unusable.
// Also rebuild `custom_llms` so adds/edits/removals to the user's custom endpoints
// immediately flow through to the model picker.
ctx.subscribe_to_model(
&ApiKeyManager::handle(ctx),
|me, _, _event: &ApiKeyManagerEvent, ctx| {
me.rebuild_custom_llms(ctx);
me.reconcile_disabled_model_preferences(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
if matches!(
event,
AISettingsChangedEvent::OpenAIEnabled { .. }
| AISettingsChangedEvent::OpenAIModels { .. }
| AISettingsChangedEvent::OpenAIBaseUrl { .. }
| AISettingsChangedEvent::OpenAIProviders { .. }
) {
me.inject_openai_models(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
});
},
);
// Rebuild custom model routers whenever the local `model_configs/` directory
// changes, and reconcile any now-stale local selection.
if FeatureFlag::CustomModelRouters.is_enabled() {
ctx.subscribe_to_model(&WarpConfig::handle(ctx), |me, _, event, ctx| {
if matches!(event, WarpConfigUpdateEvent::ModelConfigs) {
me.rebuild_custom_model_routers(ctx);
me.reconcile_stale_custom_router_selection(ctx);
}
});
}
let base_llm_for_terminal_view = HashMap::new();
let custom_llms = build_custom_llm_infos(ApiKeyManager::as_ref(ctx).keys());
let mut me = Self {
models_by_feature,
last_update: None,
base_llm_for_terminal_view,
#[cfg(not(target_family = "wasm"))]
openai_provider_routing: HashMap::new(),
custom_llms,
custom_model_routers: Vec::new(),
};
// Seed from any already-loaded local config (the async load emits
// `ModelConfigs` shortly after startup to populate fully).
if FeatureFlag::CustomModelRouters.is_enabled() {
me.rebuild_custom_model_routers(ctx);
}
// 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.)
@@ -961,7 +1041,13 @@ impl LLMPreferences {
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) {
if let Some(llm_info) = Self::server_info_for_id_router_gated(
&self.models_by_feature.agent_mode,
llm_id,
)
.or_else(|| self.custom_llm_info_for_id_if_enabled(llm_id, app))
.or_else(|| self.custom_router_llm_info_for_id_if_enabled(llm_id))
{
return llm_info;
}
}
@@ -973,7 +1059,11 @@ impl LLMPreferences {
.data()
.base_model
.clone()
.and_then(|id| self.models_by_feature.agent_mode.info_for_id(&id))
.and_then(|id| {
Self::server_info_for_id_router_gated(&self.models_by_feature.agent_mode, &id)
.or_else(|| self.custom_llm_info_for_id_if_enabled(&id, app))
.or_else(|| self.custom_router_llm_info_for_id_if_enabled(&id))
})
.unwrap_or_else(|| self.models_by_feature.agent_mode.default_llm_info())
}
@@ -997,33 +1087,76 @@ impl LLMPreferences {
.data()
.coding_model
.clone()
.and_then(|id| self.models_by_feature.coding.info_for_id(&id))
.and_then(|id| {
Self::server_info_for_id_router_gated(&self.models_by_feature.coding, &id)
.or_else(|| self.custom_llm_info_for_id_if_enabled(&id, app))
.or_else(|| self.custom_router_llm_info_for_id_if_enabled(&id))
})
.unwrap_or_else(|| self.models_by_feature.coding.default_llm_info())
}
/// Resolves `id` against a server-provided model list, but hides cloud/team
/// custom routers when the custom-router feature flag is off. Mirrors the
/// gating applied to local routers (see
/// [`Self::custom_router_llm_info_for_id_if_enabled`]) so the whole
/// custom-router feature is controlled by a single client flag.
fn server_info_for_id_router_gated<'a>(
available: &'a AvailableLLMs,
id: &LLMId,
) -> Option<&'a LLMInfo> {
let info = available.info_for_id(id)?;
if !FeatureFlag::CustomModelRouters.is_enabled()
&& custom_model_routers::is_cloud_custom_router_id(info.id.as_str())
{
return None;
}
Some(info)
}
/// Returns the set of LLMs available for Agent Mode use.
pub fn get_base_llm_choices_for_agent_mode(&self) -> impl Iterator<Item = &LLMInfo> {
pub fn get_base_llm_choices_for_agent_mode(
&self,
app: &AppContext,
) -> impl Iterator<Item = &LLMInfo> {
// Don't show admin-disabled models in the dropdown
let routers_enabled = FeatureFlag::CustomModelRouters.is_enabled();
self.models_by_feature
.agent_mode
.choices
.iter()
.filter(|llm| !matches!(llm.disable_reason, Some(DisableReason::AdminDisabled)))
// Gate cloud/team routers behind the same flag as local routers so
// the entire custom-router feature is controlled by one flag.
.filter(move |llm| {
routers_enabled || !custom_model_routers::is_cloud_custom_router_id(llm.id.as_str())
})
.chain(self.custom_llm_choices(app))
.chain(self.custom_router_choices())
}
/// Returns the set of LLMs available for coding.
pub fn get_coding_llm_choices(&self) -> impl Iterator<Item = &LLMInfo> {
pub fn get_coding_llm_choices(&self, app: &AppContext) -> impl Iterator<Item = &LLMInfo> {
// Don't show admin-disabled models in the dropdown
let routers_enabled = FeatureFlag::CustomModelRouters.is_enabled();
self.models_by_feature
.coding
.choices
.iter()
.filter(|llm| !matches!(llm.disable_reason, Some(DisableReason::AdminDisabled)))
// Gate cloud/team routers behind the same flag as local routers.
.filter(move |llm| {
routers_enabled || !custom_model_routers::is_cloud_custom_router_id(llm.id.as_str())
})
.chain(self.custom_llm_choices(app))
.chain(self.custom_router_choices())
}
/// Returns the set of LLMs available for CLI agent.
pub fn get_cli_agent_llm_choices(&self) -> impl Iterator<Item = &LLMInfo> {
self.get_cli_agent_available().choices.iter()
pub fn get_cli_agent_llm_choices(&self, app: &AppContext) -> impl Iterator<Item = &LLMInfo> {
self.get_cli_agent_available()
.choices
.iter()
.chain(self.custom_llm_choices(app))
}
/// Returns the `LLMInfo` for the CLI agent model.
@@ -1039,7 +1172,11 @@ impl LLMPreferences {
.data()
.cli_agent_model
.clone()
.and_then(|id| available.info_for_id(&id))
.and_then(|id| {
available
.info_for_id(&id)
.or_else(|| self.custom_llm_info_for_id_if_enabled(&id, app))
})
.unwrap_or_else(|| available.default_llm_info())
}
@@ -1094,8 +1231,309 @@ impl LLMPreferences {
}
/// Returns metadata about an LLM, if the client knows about it.
/// Falls back to the user's custom-endpoint LLMs when the id isn't a server-known model
/// id (e.g. when it's a `config_key` UUID).
pub fn get_llm_info(&self, id: &LLMId) -> Option<&LLMInfo> {
self.models_by_feature.info_for_id(id)
self.models_by_feature
.info_for_id(id)
.or_else(|| self.custom_llm_info_for_id(id))
.or_else(|| self.custom_router_llm_info_for_id(id))
}
/// Resolves an `LLMId` against the user's custom-endpoint LLMs.
/// Returns `None` if the id isn't a known custom model `config_key`.
pub fn custom_llm_info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> {
self.custom_llms.iter().find(|info| info.id == *id)
}
/// Footer label for custom endpoint usage keyed by the request config_key.
/// The synthetic custom LLMInfo already owns alias-or-name display semantics.
pub fn custom_endpoint_usage_display_label(&self, config_key: &str) -> String {
let config_key = LLMId::from(config_key);
self.custom_llm_info_for_id(&config_key)
.map(|info| info.display_name.as_str())
.map(str::to_string)
.unwrap_or_else(|| CUSTOM_ENDPOINT_USAGE_FALLBACK_LABEL.to_string())
}
fn custom_llm_info_for_id_if_enabled(&self, id: &LLMId, app: &AppContext) -> Option<&LLMInfo> {
Self::custom_inference_enabled(app)
.then(|| self.custom_llm_info_for_id(id))
.flatten()
}
/// Iterator over the user's custom-endpoint LLMs, gated on the feature flag and entitlement.
pub fn custom_llm_choices(&self, app: &AppContext) -> std::slice::Iter<'_, LLMInfo> {
if Self::custom_inference_enabled(app) {
self.custom_llms.iter()
} else {
// Empty slice with a matching element type so the return type stays consistent
// across both branches.
(&[] as &[LLMInfo]).iter()
}
}
fn custom_inference_enabled(app: &AppContext) -> bool {
FeatureFlag::CustomInferenceEndpoints.is_enabled()
&& UserWorkspaces::as_ref(app).is_custom_inference_enabled(app)
}
/// Resolves a custom model router by its `config_key`/`LLMId`.
pub fn custom_model_router_for_id(&self, id: &LLMId) -> Option<&CustomModelRouter> {
self.custom_model_routers.iter().find(|m| m.llm_id() == *id)
}
fn custom_router_llm_info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> {
self.custom_model_routers
.iter()
.find(|m| m.info.id == *id)
.map(|m| &m.info)
}
fn custom_router_llm_info_for_id_if_enabled(&self, id: &LLMId) -> Option<&LLMInfo> {
FeatureFlag::CustomModelRouters
.is_enabled()
.then(|| self.custom_router_llm_info_for_id(id))
.flatten()
}
/// Iterator over the custom router picker entries, gated on the feature flag.
/// Mirrors [`Self::custom_llm_choices`].
pub fn custom_router_choices(&self) -> impl Iterator<Item = &LLMInfo> {
let enabled = FeatureFlag::CustomModelRouters.is_enabled();
self.custom_model_routers
.iter()
.filter(move |_| enabled)
.map(|m| &m.info)
}
/// Builds the custom_model_routers registry for an outbound request.
pub fn custom_model_routers_for_request(
&self,
base_id: &LLMId,
coding_id: &LLMId,
) -> api::request::settings::CustomModelRouters {
let mut models = Vec::new();
let mut seen = HashSet::new();
for id in [base_id, coding_id] {
if let Some(entry) = self.custom_router_proto_entry(id) {
if seen.insert(entry.config_key.clone()) {
models.push(entry);
}
}
}
api::request::settings::CustomModelRouters { routers: models }
}
/// Returns the proto registry entry for a local custom-router id, or `None`
/// if `id` is not a known local router.
fn custom_router_proto_entry(
&self,
id: &LLMId,
) -> Option<api::request::settings::custom_model_routers::CustomModelRouter> {
self.custom_model_router_for_id(id).map(|m| m.to_proto())
}
/// Rebuilds `custom_model_routers` from the `model_configs/` directory,
/// then notifies subscribers.
///
/// Routers whose targets include an unknown model are excluded and a
/// warning is logged. The check uses the currently loaded model list
/// (server-fetched + cached), so it is best-effort at startup before
/// the server responds.
fn rebuild_custom_model_routers(&mut self, ctx: &mut ModelContext<Self>) {
let local = WarpConfig::as_ref(ctx).custom_model_routers().clone();
let mut deduped = Vec::with_capacity(local.len());
let mut seen = HashSet::new();
for model in local {
if seen.insert(model.config_key()) {
deduped.push(model);
}
}
let mut validation_errors: Vec<ModelConfigError> = Vec::new();
deduped.retain(|router| {
let unknown: Vec<&str> = router
.all_targets()
.into_iter()
.filter(|id| self.get_llm_info(&LLMId::from(*id)).is_none())
.collect();
if unknown.is_empty() {
return true;
}
let error_message = format!("unknown target model(s): {}", unknown.join(", "));
log::warn!(
"Custom model router '{}': {} — excluding from picker",
router.info.display_name,
error_message,
);
validation_errors.push(ModelConfigError {
file_name: router
.source_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or(router.info.display_name.as_str())
.to_owned(),
file_path: router.source_path.clone().unwrap_or_default(),
error_message,
});
false
});
if !validation_errors.is_empty() {
WarpConfig::handle(ctx).update(ctx, |_, ctx| {
ctx.emit(WarpConfigUpdateEvent::ModelConfigErrors(validation_errors));
});
}
// vision is supported only when every concrete target model supports it.
for router in &mut deduped {
router.info.vision_supported = router.all_targets().iter().all(|id| {
self.get_llm_info(&LLMId::from(*id))
.is_some_and(|info| info.vision_supported)
});
}
self.custom_model_routers = deduped;
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
/// Resets any persisted *local* custom-router selection that no longer resolves
/// to a loaded definition, so a deleted/invalid local config falls back to the
/// default model and the visible selection updates. Scoped to local
/// ids so a cloud selection isn't reset by a local reload.
fn reconcile_stale_custom_router_selection(&mut self, ctx: &mut ModelContext<Self>) {
let valid_local: HashSet<LLMId> = self
.custom_model_routers
.iter()
.map(|m| m.llm_id())
.collect();
let mut updated_agent_mode = false;
let mut updated_coding = false;
self.base_llm_for_terminal_view.retain(|_, id| {
let stale = custom_model_routers::is_local_custom_router_id(id.as_str())
&& !valid_local.contains(&*id);
updated_agent_mode |= stale;
!stale
});
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
for profile_id in profiles.get_all_profile_ids() {
let Some(profile) = profiles.get_profile_by_id(profile_id, ctx) else {
continue;
};
let profile_data = profile.data();
let base_stale = profile_data.base_model.as_ref().is_some_and(|id| {
custom_model_routers::is_local_custom_router_id(id.as_str())
&& !valid_local.contains(id)
});
if base_stale {
profiles.set_base_model(profile_id, None, ctx);
profiles.set_context_window_limit(profile_id, None, ctx);
updated_agent_mode = true;
}
let coding_stale = profile_data.coding_model.as_ref().is_some_and(|id| {
custom_model_routers::is_local_custom_router_id(id.as_str())
&& !valid_local.contains(id)
});
if coding_stale {
profiles.set_coding_model(profile_id, None, ctx);
updated_coding = true;
}
}
});
if updated_agent_mode {
self.trigger_snapshot_save(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM);
}
if updated_coding {
ctx.emit(LLMPreferencesEvent::UpdatedActiveCodingLLM);
}
}
/// Reads the user's current `ApiKeyManager.custom_endpoints` and replaces `custom_llms`
/// with synthetic `LLMInfo`s. Called on every `ApiKeyManagerEvent::KeysUpdated`, so adds,
/// edits, and removals all propagate immediately.
fn rebuild_custom_llms(&mut self, app: &AppContext) {
self.custom_llms = build_custom_llm_infos(ApiKeyManager::as_ref(app).keys());
}
fn sanitize_disabled_custom_model_preferences(&mut self, ctx: &mut ModelContext<Self>) {
if Self::custom_inference_enabled(ctx) || self.custom_llms.is_empty() {
return;
}
let custom_ids: HashSet<_> = self
.custom_llms
.iter()
.map(|info| info.id.clone())
.collect();
let mut updated_agent_mode = false;
let mut updated_coding = false;
let mut updated_other = false;
self.base_llm_for_terminal_view.retain(|_, id| {
let keep = !custom_ids.contains(id);
updated_agent_mode |= !keep;
keep
});
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
for profile_id in profiles.get_all_profile_ids() {
let Some(profile) = profiles.get_profile_by_id(profile_id, ctx) else {
continue;
};
let profile_data = profile.data();
if profile_data
.base_model
.as_ref()
.is_some_and(|id| custom_ids.contains(id))
{
profiles.set_base_model(profile_id, None, ctx);
profiles.set_context_window_limit(profile_id, None, ctx);
updated_agent_mode = true;
}
if profile_data
.coding_model
.as_ref()
.is_some_and(|id| custom_ids.contains(id))
{
profiles.set_coding_model(profile_id, None, ctx);
updated_coding = true;
}
if profile_data
.cli_agent_model
.as_ref()
.is_some_and(|id| custom_ids.contains(id))
{
profiles.set_cli_agent_model(profile_id, None, ctx);
updated_other = true;
}
if profile_data
.computer_use_model
.as_ref()
.is_some_and(|id| custom_ids.contains(id))
{
profiles.set_computer_use_model(profile_id, None, ctx);
updated_other = true;
}
}
});
if updated_agent_mode {
self.trigger_snapshot_save(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM);
}
if updated_coding {
ctx.emit(LLMPreferencesEvent::UpdatedActiveCodingLLM);
}
if updated_other {
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
}
/// Returns the default base model as a fallback.
@@ -1315,55 +1753,15 @@ impl LLMPreferences {
}
}
#[cfg(not(target_family = "wasm"))]
self.inject_bedrock_models(ctx);
self.reconcile_disabled_model_preferences(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);
}
}
}
}
});
// Re-evaluate custom model routers now that the server catalog is fresh.
// A router that was excluded at startup (because its target wasn't in the
// cached catalog) is reconsidered here with the authoritative model list.
if FeatureFlag::CustomModelRouters.is_enabled() {
self.rebuild_custom_model_routers(ctx);
self.reconcile_stale_custom_router_selection(ctx);
}
let new_choices =
get_new_agent_mode_choices(&old.agent_mode, &self.models_by_feature.agent_mode);
@@ -1384,6 +1782,81 @@ 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)
.or_else(|| {
self.custom_llm_info_for_id_if_enabled(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)
.or_else(|| {
self.custom_llm_info_for_id_if_enabled(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)
.or_else(|| {
self.custom_llm_info_for_id_if_enabled(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
@@ -1443,6 +1916,52 @@ fn get_new_agent_mode_choices(
.collect()
}
/// Builds synthetic [`LLMInfo`]s from the user's persisted custom endpoints.
///
/// One entry per `CustomEndpointModel`. The display label is the **alias** when present,
/// falling back to the raw model name. The `id` is the model's `config_key`, which is
/// also what flows out to `Request.Settings.custom_model_providers` so the server can map
/// a `ModelConfig.{base,coding,cli_agent,computer_use_agent}` selection back to the
/// user-provided endpoint.
///
/// Endpoints with empty URL or API key, and models with empty name or config_key, are
/// skipped — they shouldn't surface in the picker until the user finishes configuring them.
fn build_custom_llm_infos(keys: &ai::api_keys::ApiKeys) -> Vec<LLMInfo> {
keys.custom_endpoints
.iter()
.filter(|ep| !ep.url.trim().is_empty() && !ep.api_key.is_empty())
.flat_map(|endpoint| {
endpoint
.models
.iter()
.filter(|m| !m.name.trim().is_empty() && !m.config_key.is_empty())
.map(move |model| custom_llm_info_from(endpoint, model))
})
.collect()
}
fn custom_llm_info_from(endpoint: &CustomEndpoint, model: &CustomEndpointModel) -> LLMInfo {
let label = model.display_label().to_owned();
LLMInfo {
display_name: label.clone(),
base_model_name: label,
id: model.config_key.clone().into(),
reasoning_level: None,
usage_metadata: LLMUsageMetadata {
request_multiplier: 1,
credit_multiplier: None,
},
description: Some(format!("Custom · {}", endpoint.name)),
disable_reason: None,
vision_supported: true,
spec: None,
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
discount_percentage: None,
context_window: LLMContextWindow::default(),
}
}
/// Gets the last cached LLM metadata.
fn get_cached_models(app: &mut AppContext) -> Option<ModelsByFeature> {
let value = app