diff --git a/app/src/utils/__tests__/localAiBootstrap.test.ts b/app/src/utils/__tests__/localAiBootstrap.test.ts index 2fe5fc706..061534ed9 100644 --- a/app/src/utils/__tests__/localAiBootstrap.test.ts +++ b/app/src/utils/__tests__/localAiBootstrap.test.ts @@ -20,8 +20,8 @@ describe('localAiBootstrap', () => { const tauriCommands = await import('../tauriCommands'); vi.mocked(tauriCommands.openhumanLocalAiPresets).mockResolvedValue({ presets: [], - recommended_tier: 'high', - current_tier: 'medium', + recommended_tier: 'ram_2_4gb', + current_tier: 'ram_2_4gb', selected_tier: null, device: { total_ram_bytes: 32 * 1024 * 1024 * 1024, @@ -34,11 +34,11 @@ describe('localAiBootstrap', () => { }, }); vi.mocked(tauriCommands.openhumanLocalAiApplyPreset).mockResolvedValue({ - applied_tier: 'high', - chat_model_id: 'gemma3:12b-it-q4_K_M', - vision_model_id: 'gemma3:12b-it-q4_K_M', - embedding_model_id: 'nomic-embed-text:latest', - quantization: 'q4_K_M', + applied_tier: 'ram_2_4gb', + chat_model_id: 'gemma3:1b-it-qat', + vision_model_id: '', + embedding_model_id: 'all-minilm:latest', + quantization: 'qat', }); vi.mocked(tauriCommands.openhumanLocalAiDownloadAllAssets).mockResolvedValue({ result: { state: 'downloading', progress: 0 } as never, @@ -48,7 +48,7 @@ describe('localAiBootstrap', () => { const result = await bootstrapLocalAiWithRecommendedPreset(false, '[test]'); expect(tauriCommands.openhumanLocalAiPresets).toHaveBeenCalledOnce(); - expect(tauriCommands.openhumanLocalAiApplyPreset).toHaveBeenCalledWith('high'); + expect(tauriCommands.openhumanLocalAiApplyPreset).toHaveBeenCalledWith('ram_2_4gb'); expect(tauriCommands.openhumanLocalAiDownloadAllAssets).toHaveBeenCalledWith(false); expect( vi.mocked(tauriCommands.openhumanLocalAiApplyPreset).mock.invocationCallOrder[0] @@ -56,16 +56,16 @@ describe('localAiBootstrap', () => { vi.mocked(tauriCommands.openhumanLocalAiDownloadAllAssets).mock.invocationCallOrder[0] ); expect(result.preset.hadSelectedTier).toBe(false); - expect(result.preset.appliedTier).toBe('high'); + expect(result.preset.appliedTier).toBe('ram_2_4gb'); }); it('skips preset application when a tier is already selected', async () => { const tauriCommands = await import('../tauriCommands'); vi.mocked(tauriCommands.openhumanLocalAiPresets).mockResolvedValue({ presets: [], - recommended_tier: 'medium', - current_tier: 'high', - selected_tier: 'high', + recommended_tier: 'ram_2_4gb', + current_tier: 'ram_2_4gb', + selected_tier: 'ram_2_4gb', device: { total_ram_bytes: 32 * 1024 * 1024 * 1024, cpu_count: 8, @@ -81,6 +81,6 @@ describe('localAiBootstrap', () => { expect(tauriCommands.openhumanLocalAiApplyPreset).not.toHaveBeenCalled(); expect(result.hadSelectedTier).toBe(true); - expect(result.selectedTier).toBe('high'); + expect(result.selectedTier).toBe('ram_2_4gb'); }); }); diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index ffc204e1d..bc9cd1ae8 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -771,7 +771,17 @@ impl Config { if let Some(tier) = crate::openhuman::local_ai::presets::ModelTier::from_str_opt(&tier_str) { - if tier != crate::openhuman::local_ai::presets::ModelTier::Custom { + if tier == crate::openhuman::local_ai::presets::ModelTier::Custom { + tracing::warn!( + tier = %tier_str, + "ignoring custom OPENHUMAN_LOCAL_AI_TIER; only built-in presets are supported" + ); + } else if !tier.is_mvp_allowed() { + tracing::warn!( + tier = %tier_str, + "ignoring OPENHUMAN_LOCAL_AI_TIER outside the 1B local-model allowlist" + ); + } else { crate::openhuman::local_ai::presets::apply_preset_to_config( &mut self.local_ai, tier, @@ -781,7 +791,7 @@ impl Config { } else { tracing::warn!( tier = %tier_str, - "ignoring invalid OPENHUMAN_LOCAL_AI_TIER (valid: ram_1gb, ram_2_4gb, ram_4_8gb, ram_8_16gb, ram_16_plus_gb)" + "ignoring invalid OPENHUMAN_LOCAL_AI_TIER (valid: ram_2_4gb)" ); } } diff --git a/src/openhuman/config/schema/local_ai.rs b/src/openhuman/config/schema/local_ai.rs index a8f99443f..2eec92e1a 100644 --- a/src/openhuman/config/schema/local_ai.rs +++ b/src/openhuman/config/schema/local_ai.rs @@ -75,19 +75,19 @@ fn default_provider() -> String { } fn default_model_id() -> String { - "gemma3:4b-it-qat".to_string() + "gemma3:1b-it-qat".to_string() } fn default_chat_model_id() -> String { - "gemma3:4b-it-qat".to_string() + "gemma3:1b-it-qat".to_string() } fn default_vision_model_id() -> String { - "gemma3:4b-it-qat".to_string() + String::new() } fn default_embedding_model_id() -> String { - "nomic-embed-text:latest".to_string() + "all-minilm:latest".to_string() } fn default_stt_model_id() -> String { diff --git a/src/openhuman/local_ai/model_ids.rs b/src/openhuman/local_ai/model_ids.rs index 30e5431f9..01b50feb9 100644 --- a/src/openhuman/local_ai/model_ids.rs +++ b/src/openhuman/local_ai/model_ids.rs @@ -7,10 +7,10 @@ use crate::openhuman::config::Config; -pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "gemma3:4b-it-qat"; -pub(crate) const DEFAULT_OLLAMA_VISION_MODEL: &str = "gemma3:4b-it-qat"; +pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "gemma3:1b-it-qat"; +pub(crate) const DEFAULT_OLLAMA_VISION_MODEL: &str = ""; 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"; +pub(crate) const DEFAULT_OLLAMA_EMBED_MODEL: &str = "all-minilm: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`. diff --git a/src/openhuman/local_ai/presets.rs b/src/openhuman/local_ai/presets.rs index b6a029d27..b2cef192a 100644 --- a/src/openhuman/local_ai/presets.rs +++ b/src/openhuman/local_ai/presets.rs @@ -27,10 +27,8 @@ 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. +/// Single local model tier exposed in the current build. Larger local models +/// stay blocked to keep summarization lightweight and battery-friendly. pub const MVP_MAX_TIER: ModelTier = ModelTier::Ram2To4Gb; /// Minimum host RAM (in whole GB) below which the **default** is to skip @@ -125,8 +123,9 @@ pub fn all_presets() -> Vec { }, ModelPreset { tier: ModelTier::Ram2To4Gb, - label: "2-4 GB", - description: "Speed-first Gemma tier for low-memory devices. Vision disabled.", + label: "Gemma 3 1B", + description: + "Battery-friendly local summarization preset. Uses the 1B Gemma model with vision disabled.", chat_model_id: "gemma3:1b-it-qat", vision_model_id: "", embedding_model_id: "all-minilm:latest", @@ -198,17 +197,7 @@ pub fn preset_for_tier(tier: ModelTier) -> Option { /// Recommend a tier based on device capabilities. pub fn recommend_tier(device: &DeviceProfile) -> ModelTier { let ram_gb = device.total_ram_gb(); - let tier = if ram_gb >= 16 { - ModelTier::Ram16PlusGb - } else if ram_gb >= 8 { - ModelTier::Ram8To16Gb - } else if ram_gb >= 4 { - ModelTier::Ram4To8Gb - } else if ram_gb >= 2 { - ModelTier::Ram2To4Gb - } else { - ModelTier::Ram1Gb - }; + let tier = MVP_MAX_TIER; tracing::debug!(ram_gb, ?tier, "[local_ai] recommended model tier"); tier } @@ -323,11 +312,11 @@ mod tests { #[test] fn recommend_tier_scales_with_ram() { - assert_eq!(recommend_tier(&test_device(1)), ModelTier::Ram1Gb); + assert_eq!(recommend_tier(&test_device(1)), ModelTier::Ram2To4Gb); 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); + assert_eq!(recommend_tier(&test_device(4)), ModelTier::Ram2To4Gb); + assert_eq!(recommend_tier(&test_device(8)), ModelTier::Ram2To4Gb); + assert_eq!(recommend_tier(&test_device(32)), ModelTier::Ram2To4Gb); } #[test] @@ -350,10 +339,10 @@ mod tests { #[test] fn preset_application_and_round_trip() { let mut config = LocalAiConfig::default(); - apply_preset_to_config(&mut config, ModelTier::Ram1Gb); - assert_eq!(config.chat_model_id, "gemma3:270m-it-qat"); - assert_eq!(config.selected_tier, Some("ram_1gb".to_string())); - assert_eq!(current_tier_from_config(&config), ModelTier::Ram1Gb); + apply_preset_to_config(&mut config, ModelTier::Ram2To4Gb); + assert_eq!(config.chat_model_id, "gemma3:1b-it-qat"); + assert_eq!(config.selected_tier, Some("ram_2_4gb".to_string())); + assert_eq!(current_tier_from_config(&config), ModelTier::Ram2To4Gb); assert!(!config.preload_vision_model); assert_eq!(vision_mode_for_config(&config), VisionMode::Disabled); } @@ -380,8 +369,8 @@ mod tests { #[test] fn default_config_maps_to_balanced_tier() { let config = LocalAiConfig::default(); - assert_eq!(current_tier_from_config(&config), ModelTier::Ram8To16Gb); - assert_eq!(vision_mode_for_config(&config), VisionMode::Bundled); + assert_eq!(current_tier_from_config(&config), ModelTier::Ram2To4Gb); + assert_eq!(vision_mode_for_config(&config), VisionMode::Disabled); } #[test] diff --git a/src/openhuman/local_ai/schemas.rs b/src/openhuman/local_ai/schemas.rs index dc78a8821..1e759510f 100644 --- a/src/openhuman/local_ai/schemas.rs +++ b/src/openhuman/local_ai/schemas.rs @@ -420,7 +420,7 @@ pub fn schemas(function: &str) -> ControllerSchema { description: "Apply a model tier preset to local AI config and persist.", inputs: vec![required_string( "tier", - "Tier to apply: ram_1gb, ram_2_4gb, ram_4_8gb, ram_8_16gb, ram_16_plus_gb.", + "Tier to apply: ram_2_4gb, or disabled to use cloud fallback.", )], outputs: vec![json_output("result", "Applied tier status.")], }, @@ -712,7 +712,7 @@ fn handle_local_ai_presets(_params: Map) -> ControllerFuture { .map(|tier| tier.as_str().to_string()) .or_else(|| (!normalized.is_empty()).then_some(normalized)) }); - let presets = crate::openhuman::local_ai::presets::all_presets(); + let presets = crate::openhuman::local_ai::presets::mvp_presets(); tracing::debug!( ?recommended, ?current, @@ -763,7 +763,7 @@ fn handle_local_ai_apply_preset(params: Map) -> ControllerFuture let tier = crate::openhuman::local_ai::presets::ModelTier::from_str_opt(&tier_str) .ok_or_else(|| { format!( - "invalid tier '{}': expected one of disabled, ram_1gb, ram_2_4gb, ram_4_8gb, ram_8_16gb, ram_16_plus_gb", + "invalid tier '{}': expected one of disabled or ram_2_4gb", tier_str ) })?; @@ -771,6 +771,12 @@ fn handle_local_ai_apply_preset(params: Map) -> ControllerFuture if tier == crate::openhuman::local_ai::presets::ModelTier::Custom { return Err("cannot apply 'custom' tier; set model IDs directly".to_string()); } + if !tier.is_mvp_allowed() { + return Err(format!( + "tier '{}' is not available in this build; only the 1B local model preset is supported", + tier_str + )); + } let mut config = config_rpc::load_config_with_timeout().await?; // Re-enable local AI in case it was previously disabled via the diff --git a/src/openhuman/local_ai/schemas_tests.rs b/src/openhuman/local_ai/schemas_tests.rs index 9d00820f9..65d75ad96 100644 --- a/src/openhuman/local_ai/schemas_tests.rs +++ b/src/openhuman/local_ai/schemas_tests.rs @@ -161,6 +161,17 @@ async fn handle_presets_returns_presets_list_and_recommended_tier() { assert!(v.get("presets").is_some()); assert!(v.get("recommended_tier").is_some()); assert!(v.get("device").is_some()); + let presets = v + .get("presets") + .and_then(|value| value.as_array()) + .expect("presets array"); + assert_eq!(presets.len(), 1, "only the 1B preset should be exposed"); + assert_eq!( + presets[0] + .get("chat_model_id") + .and_then(|value| value.as_str()), + Some("gemma3:1b-it-qat") + ); } #[tokio::test] @@ -193,6 +204,21 @@ async fn handle_apply_preset_rejects_custom_tier() { assert!(err.contains("cannot apply 'custom'")); } +#[tokio::test] +async fn handle_apply_preset_rejects_unsupported_large_tier() { + let _g = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path()); + } + let params = Map::from_iter([("tier".to_string(), serde_json::json!("ram_8_16gb"))]); + let err = handle_local_ai_apply_preset(params).await.unwrap_err(); + unsafe { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } + assert!(err.contains("only the 1B local model preset is supported")); +} + #[tokio::test] async fn handle_apply_preset_accepts_valid_tier_and_persists() { let _g = ENV_LOCK.lock().unwrap(); diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 989f9f3eb..09689ac15 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1856,32 +1856,34 @@ async fn json_rpc_local_ai_device_profile_and_presets() { .get("presets") .and_then(Value::as_array) .expect("presets should be an array"); - assert_eq!(presets_arr.len(), 5, "expected 5 presets: {presets_result}"); + assert_eq!( + presets_arr.len(), + 1, + "MVP exposes only the 1B preset: {presets_result}" + ); + assert_eq!( + presets_arr[0].get("tier").and_then(Value::as_str), + Some("ram_2_4gb"), + "only the ram_2_4gb (1B) preset should be exposed: {presets_result}" + ); let recommended = presets_result .get("recommended_tier") .and_then(Value::as_str) .expect("should have recommended_tier"); - assert!( - [ - "ram_1gb", - "ram_2_4gb", - "ram_4_8gb", - "ram_8_16gb", - "ram_16_plus_gb", - ] - .contains(&recommended), - "unexpected recommended_tier: {recommended}" + assert_eq!( + recommended, "ram_2_4gb", + "MVP recommends the only allowed tier: {recommended}" ); let current = presets_result .get("current_tier") .and_then(Value::as_str) .expect("should have current_tier"); - // Default config uses gemma3:4b-it-qat which now maps to the 8-16 GB tier. + // Default config now uses gemma3:1b-it-qat which maps to the only allowed (2-4 GB) tier. assert_eq!( - current, "ram_8_16gb", - "default config should be the 8-16 GB tier" + current, "ram_2_4gb", + "default config should be the 1B / 2-4 GB tier" ); // --- apply_preset (switch to 2-4 GB) ---