feat(local_ai): MVP model lockdown — lock selection to 2-4 GB tier (#573) (#588)

* feat(local_ai): add MVP tier ceiling and cap model recommendation (#573)

Introduce MVP_MAX_TIER constant (Ram2To4Gb), is_mvp_allowed() gate,
mvp_presets() filter, and cap recommend_tier() so auto-provisioning
never selects a model above the MVP ceiling regardless of device RAM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(local_ai): enforce MVP model allowlists on resolved IDs (#573)

Add per-category allowlists (chat, vision, embedding) so that
effective_*_model_id() silently redirects any non-MVP model to the
default. Prevents config-file edits from bypassing the 2-4 GB tier
restriction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(local_ai): reject non-MVP tiers in RPC and clamp at bootstrap (#573)

apply_preset handler now returns an error for tiers above the MVP
ceiling. Bootstrap clamps any existing out-of-range tier selection
down to the recommended (capped) preset on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(ui): lock model tier selection and show full roadmap (#573)

Replace clickable tier buttons with static cards. Active tier shows
"Active" badge; locked tiers show "Coming soon" with reduced opacity.
Add MVP info banner. Fix download size to 1 decimal place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(local_ai): resolve CI failures — fmt, unused props, dead code (#573)

Apply cargo fmt to single-element array constants. Remove unused
isApplyingPreset/onApplyPreset props and applyPreset function from
the settings panel since tier switching is disabled for MVP.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply Prettier formatting to settings panels (#573)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-04-16 04:50:28 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent 098206cea7
commit f014058417
6 changed files with 217 additions and 82 deletions
@@ -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<PresetsResponse | null>(null);
const [presetsLoading, setPresetsLoading] = useState(true);
const [isApplyingPreset, setIsApplyingPreset] = useState(false);
const [presetError, setPresetError] = useState('');
const [presetSuccess, setPresetSuccess] = useState<ApplyPresetResult | null>(null);
const [presetSuccess] = useState<ApplyPresetResult | null>(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}
/>
@@ -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 && (
<div className="space-y-2">
<div className="rounded-lg border border-primary-200 bg-primary-50 p-3 text-xs text-primary-700">
The local AI model is fixed for the MVP release. Broader model options will be available
in a future update.
</div>
{presetsData.presets.map(preset => {
const isRecommended = preset.tier === presetsData.recommended_tier;
const isCurrent = preset.tier === presetsData.current_tier;
const isLocked = !isCurrent;
return (
<button
<div
key={preset.tier}
type="button"
onClick={() => onApplyPreset(preset.tier)}
disabled={isApplyingPreset || isCurrent}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
className={`w-full text-left rounded-lg border p-3 ${
isCurrent
? 'border-primary-400 bg-primary-50'
: 'border-stone-200 bg-white hover:border-stone-300'
} ${isApplyingPreset ? 'opacity-60' : ''}`}>
: 'border-stone-200 bg-stone-50 opacity-50'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-stone-900">{preset.label}</span>
{isRecommended && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-emerald-50 text-emerald-700 uppercase tracking-wide">
Recommended
</span>
)}
{isCurrent && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-50 text-primary-600 uppercase tracking-wide">
Active
</span>
)}
{isLocked && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-stone-100 text-stone-400 uppercase tracking-wide">
Coming soon
</span>
)}
</div>
<span className="text-xs text-stone-500">~{preset.approx_download_gb} GB</span>
<span className="text-xs text-stone-500">
~{Number(preset.approx_download_gb).toFixed(1)} GB
</span>
</div>
<div className="text-xs text-stone-400 mt-1">{preset.description}</div>
<div className="text-[10px] text-stone-500 mt-1">
@@ -105,7 +104,7 @@ const DeviceCapabilitySection = ({
: preset.vision_model_id || preset.vision_mode}{' '}
&middot; Target RAM: {preset.target_ram_gb} GB
</div>
</button>
</div>
);
})}
+95 -17
View File
@@ -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 (24 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
/// 24 GB tier has no vision model.
const MVP_ALLOWED_VISION_MODELS: &[&str] = &[""];
/// Embedding models allowed in MVP (24 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]
+58 -7
View File
@@ -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<Self> {
match s.to_ascii_lowercase().as_str() {
"ram_1gb" | "tier_1gb" | "1gb" => Some(Self::Ram1Gb),
@@ -153,15 +164,26 @@ pub fn all_presets() -> Vec<ModelPreset> {
]
}
/// Return only the presets allowed under the current MVP ceiling.
pub fn mvp_presets() -> Vec<ModelPreset> {
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<ModelPreset> {
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]
+13 -1
View File
@@ -788,6 +788,18 @@ fn handle_local_ai_apply_preset(params: Map<String, Value>) -> 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");
+32 -16
View File
@@ -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");
}
}