mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(local-ai): cap local models to 1b preset (#1029)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user