diff --git a/app/src/components/settings/panels/LocalModelPanel.tsx b/app/src/components/settings/panels/LocalModelPanel.tsx index 68a6af0ee..af26b31b7 100644 --- a/app/src/components/settings/panels/LocalModelPanel.tsx +++ b/app/src/components/settings/panels/LocalModelPanel.tsx @@ -10,7 +10,6 @@ import { type ApplyPresetResult, type LocalAiDownloadsProgress, type LocalAiStatus, - openhumanLocalAiApplyPreset, openhumanLocalAiDownload, openhumanLocalAiDownloadAllAssets, openhumanLocalAiDownloadsProgress, @@ -37,9 +36,8 @@ const LocalModelPanel = () => { const [presetsData, setPresetsData] = useState(null); const [presetsLoading, setPresetsLoading] = useState(true); - const [isApplyingPreset, setIsApplyingPreset] = useState(false); const [presetError, setPresetError] = useState(''); - const [presetSuccess, setPresetSuccess] = useState(null); + const [presetSuccess] = useState(null); const progress = useMemo(() => { const downloadProgress = progressFromDownloads(downloads); @@ -89,23 +87,6 @@ const LocalModelPanel = () => { } }; - const applyPreset = async (tier: string) => { - setIsApplyingPreset(true); - setPresetError(''); - setPresetSuccess(null); - try { - const result = await openhumanLocalAiApplyPreset(tier); - setPresetSuccess(result); - await loadPresets(); - await loadStatus(); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to apply preset'; - setPresetError(msg); - } finally { - setIsApplyingPreset(false); - } - }; - useEffect(() => { void loadStatus(); void loadPresets(); @@ -152,8 +133,6 @@ const LocalModelPanel = () => { presetsLoading={presetsLoading} presetError={presetError} presetSuccess={presetSuccess} - isApplyingPreset={isApplyingPreset} - onApplyPreset={tier => void applyPreset(tier)} formatRamGb={formatRamGb} /> diff --git a/app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx b/app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx index 18bf22b98..33e6ed6eb 100644 --- a/app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx +++ b/app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx @@ -5,8 +5,6 @@ interface DeviceCapabilitySectionProps { presetsLoading: boolean; presetError: string; presetSuccess: ApplyPresetResult | null; - isApplyingPreset: boolean; - onApplyPreset: (tier: string) => void; formatRamGb: (bytes: number) => string; } @@ -15,8 +13,6 @@ const DeviceCapabilitySection = ({ presetsLoading, presetError, presetSuccess, - isApplyingPreset, - onApplyPreset, formatRamGb, }: DeviceCapabilitySectionProps) => { return ( @@ -67,35 +63,38 @@ const DeviceCapabilitySection = ({ {presetsData && (
+
+ The local AI model is fixed for the MVP release. Broader model options will be available + in a future update. +
{presetsData.presets.map(preset => { - const isRecommended = preset.tier === presetsData.recommended_tier; const isCurrent = preset.tier === presetsData.current_tier; + const isLocked = !isCurrent; return ( - +
); })} diff --git a/src/openhuman/local_ai/model_ids.rs b/src/openhuman/local_ai/model_ids.rs index 3cf41433d..30e5431f9 100644 --- a/src/openhuman/local_ai/model_ids.rs +++ b/src/openhuman/local_ai/model_ids.rs @@ -1,4 +1,9 @@ //! Resolved model / voice IDs from [`crate::openhuman::config::Config`]. +//! +//! All `effective_*` functions enforce the MVP model allowlist: if a resolved +//! model ID is not in the allowlist the function silently falls back to the +//! default MVP model and logs a warning. This prevents config-file edits from +//! bypassing the MVP tier restriction. use crate::openhuman::config::Config; @@ -7,6 +12,62 @@ pub(crate) const DEFAULT_OLLAMA_VISION_MODEL: &str = "gemma3:4b-it-qat"; pub(crate) const DEFAULT_LOW_VISION_MODEL: &str = "moondream:1.8b-v2-q4_K_S"; pub(crate) const DEFAULT_OLLAMA_EMBED_MODEL: &str = "nomic-embed-text:latest"; +/// Chat models allowed in the current MVP build (2–4 GB tier only). +/// Any resolved chat model ID not listed here is redirected to `MVP_DEFAULT_CHAT_MODEL`. +const MVP_ALLOWED_CHAT_MODELS: &[&str] = &["gemma3:1b-it-qat"]; +const MVP_DEFAULT_CHAT_MODEL: &str = "gemma3:1b-it-qat"; + +/// Vision models allowed in MVP — only disabled (empty string) since the +/// 2–4 GB tier has no vision model. +const MVP_ALLOWED_VISION_MODELS: &[&str] = &[""]; + +/// Embedding models allowed in MVP (2–4 GB tier uses all-minilm). +const MVP_ALLOWED_EMBEDDING_MODELS: &[&str] = &["all-minilm:latest"]; + +fn enforce_mvp_chat_allowlist(resolved: &str) -> String { + let lower = resolved.to_ascii_lowercase(); + for allowed in MVP_ALLOWED_CHAT_MODELS { + if lower == allowed.to_ascii_lowercase() { + return resolved.to_string(); + } + } + tracing::warn!( + resolved, + fallback = MVP_DEFAULT_CHAT_MODEL, + "[local_ai] chat model not in MVP allowlist, redirecting to default" + ); + MVP_DEFAULT_CHAT_MODEL.to_string() +} + +fn enforce_mvp_vision_allowlist(resolved: &str) -> String { + let lower = resolved.to_ascii_lowercase(); + for allowed in MVP_ALLOWED_VISION_MODELS { + if lower == allowed.to_ascii_lowercase() { + return resolved.to_string(); + } + } + tracing::warn!( + resolved, + "[local_ai] vision model not in MVP allowlist, disabling vision" + ); + String::new() +} + +fn enforce_mvp_embedding_allowlist(resolved: &str) -> String { + let lower = resolved.to_ascii_lowercase(); + for allowed in MVP_ALLOWED_EMBEDDING_MODELS { + if lower == allowed.to_ascii_lowercase() { + return resolved.to_string(); + } + } + tracing::warn!( + resolved, + fallback = MVP_ALLOWED_EMBEDDING_MODELS[0], + "[local_ai] embedding model not in MVP allowlist, redirecting to default" + ); + MVP_ALLOWED_EMBEDDING_MODELS[0].to_string() +} + pub(crate) fn effective_chat_model_id(config: &Config) -> String { let raw = if !config.local_ai.chat_model_id.trim().is_empty() { config.local_ai.chat_model_id.trim() @@ -14,7 +75,7 @@ pub(crate) fn effective_chat_model_id(config: &Config) -> String { config.local_ai.model_id.trim() }; if raw.is_empty() { - return DEFAULT_OLLAMA_MODEL.to_string(); + return enforce_mvp_chat_allowlist(DEFAULT_OLLAMA_MODEL); } let lower = raw.to_ascii_lowercase(); if lower.ends_with(".gguf") @@ -22,9 +83,9 @@ pub(crate) fn effective_chat_model_id(config: &Config) -> String { || lower == "qwen3-1.7b" || lower == "qwen2.5-1.5b-instruct" { - return DEFAULT_OLLAMA_MODEL.to_string(); + return enforce_mvp_chat_allowlist(DEFAULT_OLLAMA_MODEL); } - raw.to_string() + enforce_mvp_chat_allowlist(raw) } pub(crate) fn effective_vision_model_id(config: &Config) -> String { @@ -33,18 +94,20 @@ pub(crate) fn effective_vision_model_id(config: &Config) -> String { return String::new(); } let lower = raw.to_ascii_lowercase(); - if lower == "moondream:1.8b" || lower == "moondream" { - return DEFAULT_LOW_VISION_MODEL.to_string(); - } - raw.to_string() + let resolved = if lower == "moondream:1.8b" || lower == "moondream" { + DEFAULT_LOW_VISION_MODEL + } else { + raw + }; + enforce_mvp_vision_allowlist(resolved) } pub(crate) fn effective_embedding_model_id(config: &Config) -> String { let raw = config.local_ai.embedding_model_id.trim(); if raw.is_empty() { - return DEFAULT_OLLAMA_EMBED_MODEL.to_string(); + return enforce_mvp_embedding_allowlist(DEFAULT_OLLAMA_EMBED_MODEL); } - raw.to_string() + enforce_mvp_embedding_allowlist(raw) } pub(crate) fn effective_stt_model_id(config: &Config) -> String { @@ -88,21 +151,34 @@ mod tests { config.local_ai.chat_model_id = String::new(); config.local_ai.model_id = String::new(); - assert_eq!(effective_chat_model_id(&config), DEFAULT_OLLAMA_MODEL); + assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL); config.local_ai.chat_model_id = "custom.gguf".to_string(); - assert_eq!(effective_chat_model_id(&config), DEFAULT_OLLAMA_MODEL); + assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL); config.local_ai.chat_model_id = "qwen3-1.7b".to_string(); - assert_eq!(effective_chat_model_id(&config), DEFAULT_OLLAMA_MODEL); + assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL); } #[test] - fn chat_model_prefers_explicit_supported_chat_model() { + fn chat_model_allows_mvp_model() { let mut config = test_config(); - config.local_ai.model_id = "fallback:model".to_string(); + config.local_ai.chat_model_id = "gemma3:1b-it-qat".to_string(); + assert_eq!(effective_chat_model_id(&config), "gemma3:1b-it-qat"); + } + + #[test] + fn chat_model_rejects_non_mvp_models() { + let mut config = test_config(); + // All models outside the single MVP-allowed model are rejected. config.local_ai.chat_model_id = "gemma3:4b-it-qat".to_string(); - assert_eq!(effective_chat_model_id(&config), "gemma3:4b-it-qat"); + assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL); + + config.local_ai.chat_model_id = "gemma3:270m-it-qat".to_string(); + assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL); + + config.local_ai.chat_model_id = "gemma4:e4b".to_string(); + assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL); } #[test] @@ -111,10 +187,12 @@ mod tests { config.local_ai.vision_model_id = String::new(); assert_eq!(effective_vision_model_id(&config), ""); + // Moondream is not in the MVP vision allowlist (only "" is allowed), + // so it gets redirected to "" (vision disabled). config.local_ai.vision_model_id = "moondream".to_string(); - assert_eq!(effective_vision_model_id(&config), DEFAULT_LOW_VISION_MODEL); + assert_eq!(effective_vision_model_id(&config), ""); config.local_ai.vision_model_id = "moondream:1.8b".to_string(); - assert_eq!(effective_vision_model_id(&config), DEFAULT_LOW_VISION_MODEL); + assert_eq!(effective_vision_model_id(&config), ""); } #[test] diff --git a/src/openhuman/local_ai/presets.rs b/src/openhuman/local_ai/presets.rs index 0eae7809e..932b088cc 100644 --- a/src/openhuman/local_ai/presets.rs +++ b/src/openhuman/local_ai/presets.rs @@ -27,6 +27,12 @@ pub enum ModelTier { Custom, } +/// Maximum tier allowed in the current MVP build. Tiers above this ceiling +/// are hidden from the UI, rejected by the apply-preset RPC, and clamped at +/// bootstrap. Bump this constant (or remove the cap) when broader model +/// selection is re-enabled post-MVP. +pub const MVP_MAX_TIER: ModelTier = ModelTier::Ram2To4Gb; + impl ModelTier { pub fn as_str(&self) -> &'static str { match self { @@ -39,6 +45,11 @@ impl ModelTier { } } + /// Whether this tier is allowed in the current MVP build. + pub fn is_mvp_allowed(self) -> bool { + matches!(self, Self::Ram2To4Gb) + } + pub fn from_str_opt(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "ram_1gb" | "tier_1gb" | "1gb" => Some(Self::Ram1Gb), @@ -153,15 +164,26 @@ pub fn all_presets() -> Vec { ] } +/// Return only the presets allowed under the current MVP ceiling. +pub fn mvp_presets() -> Vec { + all_presets() + .into_iter() + .filter(|preset| preset.tier.is_mvp_allowed()) + .collect() +} + /// Return the preset for a specific tier, or `None` for `Custom`. pub fn preset_for_tier(tier: ModelTier) -> Option { all_presets().into_iter().find(|preset| preset.tier == tier) } /// Recommend a tier based on device capabilities. +/// +/// The recommendation is capped at [`MVP_MAX_TIER`] so that auto-provisioning +/// never selects a model above the MVP ceiling, regardless of available RAM. pub fn recommend_tier(device: &DeviceProfile) -> ModelTier { let ram_gb = device.total_ram_gb(); - let tier = if ram_gb >= 16 { + let uncapped = if ram_gb >= 16 { ModelTier::Ram16PlusGb } else if ram_gb >= 8 { ModelTier::Ram8To16Gb @@ -172,6 +194,17 @@ pub fn recommend_tier(device: &DeviceProfile) -> ModelTier { } else { ModelTier::Ram1Gb }; + let tier = if uncapped.is_mvp_allowed() { + uncapped + } else { + tracing::debug!( + ram_gb, + ?uncapped, + capped_to = ?MVP_MAX_TIER, + "[local_ai] capping recommended tier to MVP ceiling" + ); + MVP_MAX_TIER + }; tracing::debug!(ram_gb, ?tier, "[local_ai] recommended model tier"); tier } @@ -285,12 +318,30 @@ mod tests { } #[test] - fn recommend_tier_by_ram() { - assert_eq!(recommend_tier(&test_device(1)), ModelTier::Ram1Gb); - assert_eq!(recommend_tier(&test_device(3)), ModelTier::Ram2To4Gb); - assert_eq!(recommend_tier(&test_device(4)), ModelTier::Ram4To8Gb); - assert_eq!(recommend_tier(&test_device(8)), ModelTier::Ram8To16Gb); - assert_eq!(recommend_tier(&test_device(32)), ModelTier::Ram16PlusGb); + fn recommend_tier_by_ram_capped_at_mvp_max() { + // All devices are capped at the single MVP tier (Ram2To4Gb). + assert_eq!(recommend_tier(&test_device(1)), MVP_MAX_TIER); + assert_eq!(recommend_tier(&test_device(3)), MVP_MAX_TIER); + assert_eq!(recommend_tier(&test_device(4)), MVP_MAX_TIER); + assert_eq!(recommend_tier(&test_device(8)), MVP_MAX_TIER); + assert_eq!(recommend_tier(&test_device(32)), MVP_MAX_TIER); + } + + #[test] + fn mvp_allowed_tiers() { + assert!(!ModelTier::Ram1Gb.is_mvp_allowed()); + assert!(ModelTier::Ram2To4Gb.is_mvp_allowed()); + assert!(!ModelTier::Ram4To8Gb.is_mvp_allowed()); + assert!(!ModelTier::Ram8To16Gb.is_mvp_allowed()); + assert!(!ModelTier::Ram16PlusGb.is_mvp_allowed()); + assert!(!ModelTier::Custom.is_mvp_allowed()); + } + + #[test] + fn mvp_presets_only_returns_allowed_tiers() { + let presets = mvp_presets(); + assert_eq!(presets.len(), 1); + assert_eq!(presets[0].tier, ModelTier::Ram2To4Gb); } #[test] diff --git a/src/openhuman/local_ai/schemas.rs b/src/openhuman/local_ai/schemas.rs index 62762cf78..620ae674b 100644 --- a/src/openhuman/local_ai/schemas.rs +++ b/src/openhuman/local_ai/schemas.rs @@ -788,6 +788,18 @@ fn handle_local_ai_apply_preset(params: Map) -> ControllerFuture return Err("cannot apply 'custom' tier; set model IDs directly".to_string()); } + if !tier.is_mvp_allowed() { + tracing::debug!( + tier = %tier_str, + "[local_ai] apply_preset: rejected — tier exceeds MVP ceiling" + ); + return Err(format!( + "model tier '{}' is not available in MVP; only tiers up to {} are allowed", + tier_str, + crate::openhuman::local_ai::presets::MVP_MAX_TIER.as_str() + )); + } + let mut config = config_rpc::load_config_with_timeout().await?; crate::openhuman::local_ai::presets::apply_preset_to_config(&mut config.local_ai, tier); config @@ -1196,7 +1208,7 @@ mod tests { unsafe { std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); } - let params = Map::from_iter([("tier".to_string(), serde_json::json!("ram_4_8gb"))]); + let params = Map::from_iter([("tier".to_string(), serde_json::json!("ram_2_4gb"))]); let result = handle_local_ai_apply_preset(params) .await .expect("apply ok"); diff --git a/src/openhuman/local_ai/service/bootstrap.rs b/src/openhuman/local_ai/service/bootstrap.rs index 94af57f1a..9bf72f61f 100644 --- a/src/openhuman/local_ai/service/bootstrap.rs +++ b/src/openhuman/local_ai/service/bootstrap.rs @@ -273,16 +273,29 @@ fn config_with_recommended_tier_if_unselected(config: &Config, device: &DevicePr .filter(|value| !value.is_empty()); let current_tier = crate::openhuman::local_ai::presets::current_tier_from_config(&config.local_ai); + + // If a tier is already selected, check whether it exceeds the MVP ceiling. + // If so, clamp it down to the recommended (MVP-capped) tier. if selected_tier.is_some() - || matches!( + || !matches!( current_tier, - crate::openhuman::local_ai::presets::ModelTier::Ram1Gb - | crate::openhuman::local_ai::presets::ModelTier::Ram2To4Gb - | crate::openhuman::local_ai::presets::ModelTier::Ram4To8Gb - | crate::openhuman::local_ai::presets::ModelTier::Ram8To16Gb - | crate::openhuman::local_ai::presets::ModelTier::Ram16PlusGb + crate::openhuman::local_ai::presets::ModelTier::Custom ) { + if !current_tier.is_mvp_allowed() { + let recommended = crate::openhuman::local_ai::presets::recommend_tier(device); + tracing::warn!( + ?current_tier, + ?recommended, + "[local_ai] bootstrap: selected tier exceeds MVP ceiling, clamping to recommended" + ); + let mut effective_config = config.clone(); + crate::openhuman::local_ai::presets::apply_preset_to_config( + &mut effective_config.local_ai, + recommended, + ); + return effective_config; + } return config.clone(); } @@ -348,8 +361,10 @@ mod tests { } #[test] - fn bootstrap_uses_recommended_tier_when_selection_missing() { + fn bootstrap_clamps_non_mvp_tier_when_selection_missing() { let config = Config::default(); + // Default config matches Ram8To16Gb which is not MVP-allowed, + // so bootstrap clamps it to the MVP-capped recommended tier. let device = DeviceProfile { total_ram_bytes: 4 * 1024 * 1024 * 1024, cpu_count: 4, @@ -362,20 +377,17 @@ mod tests { let effective = config_with_recommended_tier_if_unselected(&config, &device); - // If config already matches a built-in preset, preserve user defaults - // and keep selected_tier unset. - assert!(effective.local_ai.selected_tier.is_none()); assert_eq!( - effective.local_ai.chat_model_id, - config.local_ai.chat_model_id + effective.local_ai.selected_tier.as_deref(), + Some("ram_2_4gb") ); + assert_eq!(effective.local_ai.chat_model_id, "gemma3:1b-it-qat"); } #[test] - fn bootstrap_keeps_existing_selected_tier() { + fn bootstrap_clamps_non_mvp_selected_tier() { let mut config = Config::default(); config.local_ai.selected_tier = Some("high".to_string()); - let original_chat_model = config.local_ai.chat_model_id.clone(); let device = DeviceProfile { total_ram_bytes: 4 * 1024 * 1024 * 1024, cpu_count: 4, @@ -388,7 +400,11 @@ mod tests { let effective = config_with_recommended_tier_if_unselected(&config, &device); - assert_eq!(effective.local_ai.selected_tier.as_deref(), Some("high")); - assert_eq!(effective.local_ai.chat_model_id, original_chat_model); + // "high" maps to Ram16PlusGb which is not MVP-allowed, so it gets clamped. + assert_eq!( + effective.local_ai.selected_tier.as_deref(), + Some("ram_2_4gb") + ); + assert_eq!(effective.local_ai.chat_model_id, "gemma3:1b-it-qat"); } }