fix(local-ai): cap local models to 1b preset (#1029)

This commit is contained in:
Steven Enamakel
2026-04-29 18:39:03 -07:00
committed by GitHub
parent e943a1ad1b
commit ac97a96d3a
8 changed files with 99 additions and 66 deletions
@@ -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');
});
});
+12 -2
View File
@@ -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)"
);
}
}
+4 -4
View File
@@ -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 {
+3 -3
View File
@@ -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 (24 GB tier only).
/// Any resolved chat model ID not listed here is redirected to `MVP_DEFAULT_CHAT_MODEL`.
+16 -27
View File
@@ -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> {
},
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<ModelPreset> {
/// 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]
+9 -3
View File
@@ -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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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
+26
View File
@@ -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();
+16 -14
View File
@@ -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) ---