Skip to content

Commit f014058

Browse files
oxoxDevclaude
andauthored
feat(local_ai): MVP model lockdown — lock selection to 2-4 GB tier (tinyhumansai#573) (tinyhumansai#588)
* feat(local_ai): add MVP tier ceiling and cap model recommendation (tinyhumansai#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 (tinyhumansai#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 (tinyhumansai#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 (tinyhumansai#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 (tinyhumansai#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 (tinyhumansai#573) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 098206c commit f014058

6 files changed

Lines changed: 217 additions & 82 deletions

File tree

app/src/components/settings/panels/LocalModelPanel.tsx

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
type ApplyPresetResult,
1111
type LocalAiDownloadsProgress,
1212
type LocalAiStatus,
13-
openhumanLocalAiApplyPreset,
1413
openhumanLocalAiDownload,
1514
openhumanLocalAiDownloadAllAssets,
1615
openhumanLocalAiDownloadsProgress,
@@ -37,9 +36,8 @@ const LocalModelPanel = () => {
3736

3837
const [presetsData, setPresetsData] = useState<PresetsResponse | null>(null);
3938
const [presetsLoading, setPresetsLoading] = useState(true);
40-
const [isApplyingPreset, setIsApplyingPreset] = useState(false);
4139
const [presetError, setPresetError] = useState('');
42-
const [presetSuccess, setPresetSuccess] = useState<ApplyPresetResult | null>(null);
40+
const [presetSuccess] = useState<ApplyPresetResult | null>(null);
4341

4442
const progress = useMemo(() => {
4543
const downloadProgress = progressFromDownloads(downloads);
@@ -89,23 +87,6 @@ const LocalModelPanel = () => {
8987
}
9088
};
9189

92-
const applyPreset = async (tier: string) => {
93-
setIsApplyingPreset(true);
94-
setPresetError('');
95-
setPresetSuccess(null);
96-
try {
97-
const result = await openhumanLocalAiApplyPreset(tier);
98-
setPresetSuccess(result);
99-
await loadPresets();
100-
await loadStatus();
101-
} catch (err) {
102-
const msg = err instanceof Error ? err.message : 'Failed to apply preset';
103-
setPresetError(msg);
104-
} finally {
105-
setIsApplyingPreset(false);
106-
}
107-
};
108-
10990
useEffect(() => {
11091
void loadStatus();
11192
void loadPresets();
@@ -152,8 +133,6 @@ const LocalModelPanel = () => {
152133
presetsLoading={presetsLoading}
153134
presetError={presetError}
154135
presetSuccess={presetSuccess}
155-
isApplyingPreset={isApplyingPreset}
156-
onApplyPreset={tier => void applyPreset(tier)}
157136
formatRamGb={formatRamGb}
158137
/>
159138

app/src/components/settings/panels/local-model/DeviceCapabilitySection.tsx

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ interface DeviceCapabilitySectionProps {
55
presetsLoading: boolean;
66
presetError: string;
77
presetSuccess: ApplyPresetResult | null;
8-
isApplyingPreset: boolean;
9-
onApplyPreset: (tier: string) => void;
108
formatRamGb: (bytes: number) => string;
119
}
1210

@@ -15,8 +13,6 @@ const DeviceCapabilitySection = ({
1513
presetsLoading,
1614
presetError,
1715
presetSuccess,
18-
isApplyingPreset,
19-
onApplyPreset,
2016
formatRamGb,
2117
}: DeviceCapabilitySectionProps) => {
2218
return (
@@ -67,35 +63,38 @@ const DeviceCapabilitySection = ({
6763

6864
{presetsData && (
6965
<div className="space-y-2">
66+
<div className="rounded-lg border border-primary-200 bg-primary-50 p-3 text-xs text-primary-700">
67+
The local AI model is fixed for the MVP release. Broader model options will be available
68+
in a future update.
69+
</div>
7070
{presetsData.presets.map(preset => {
71-
const isRecommended = preset.tier === presetsData.recommended_tier;
7271
const isCurrent = preset.tier === presetsData.current_tier;
72+
const isLocked = !isCurrent;
7373
return (
74-
<button
74+
<div
7575
key={preset.tier}
76-
type="button"
77-
onClick={() => onApplyPreset(preset.tier)}
78-
disabled={isApplyingPreset || isCurrent}
79-
className={`w-full text-left rounded-lg border p-3 transition-colors ${
76+
className={`w-full text-left rounded-lg border p-3 ${
8077
isCurrent
8178
? 'border-primary-400 bg-primary-50'
82-
: 'border-stone-200 bg-white hover:border-stone-300'
83-
} ${isApplyingPreset ? 'opacity-60' : ''}`}>
79+
: 'border-stone-200 bg-stone-50 opacity-50'
80+
}`}>
8481
<div className="flex items-center justify-between">
8582
<div className="flex items-center gap-2">
8683
<span className="text-sm font-semibold text-stone-900">{preset.label}</span>
87-
{isRecommended && (
88-
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-emerald-50 text-emerald-700 uppercase tracking-wide">
89-
Recommended
90-
</span>
91-
)}
9284
{isCurrent && (
9385
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-50 text-primary-600 uppercase tracking-wide">
9486
Active
9587
</span>
9688
)}
89+
{isLocked && (
90+
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-stone-100 text-stone-400 uppercase tracking-wide">
91+
Coming soon
92+
</span>
93+
)}
9794
</div>
98-
<span className="text-xs text-stone-500">~{preset.approx_download_gb} GB</span>
95+
<span className="text-xs text-stone-500">
96+
~{Number(preset.approx_download_gb).toFixed(1)} GB
97+
</span>
9998
</div>
10099
<div className="text-xs text-stone-400 mt-1">{preset.description}</div>
101100
<div className="text-[10px] text-stone-500 mt-1">
@@ -105,7 +104,7 @@ const DeviceCapabilitySection = ({
105104
: preset.vision_model_id || preset.vision_mode}{' '}
106105
&middot; Target RAM: {preset.target_ram_gb} GB
107106
</div>
108-
</button>
107+
</div>
109108
);
110109
})}
111110

src/openhuman/local_ai/model_ids.rs

Lines changed: 95 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
//! Resolved model / voice IDs from [`crate::openhuman::config::Config`].
2+
//!
3+
//! All `effective_*` functions enforce the MVP model allowlist: if a resolved
4+
//! model ID is not in the allowlist the function silently falls back to the
5+
//! default MVP model and logs a warning. This prevents config-file edits from
6+
//! bypassing the MVP tier restriction.
27
38
use crate::openhuman::config::Config;
49

@@ -7,24 +12,80 @@ pub(crate) const DEFAULT_OLLAMA_VISION_MODEL: &str = "gemma3:4b-it-qat";
712
pub(crate) const DEFAULT_LOW_VISION_MODEL: &str = "moondream:1.8b-v2-q4_K_S";
813
pub(crate) const DEFAULT_OLLAMA_EMBED_MODEL: &str = "nomic-embed-text:latest";
914

15+
/// Chat models allowed in the current MVP build (2–4 GB tier only).
16+
/// Any resolved chat model ID not listed here is redirected to `MVP_DEFAULT_CHAT_MODEL`.
17+
const MVP_ALLOWED_CHAT_MODELS: &[&str] = &["gemma3:1b-it-qat"];
18+
const MVP_DEFAULT_CHAT_MODEL: &str = "gemma3:1b-it-qat";
19+
20+
/// Vision models allowed in MVP — only disabled (empty string) since the
21+
/// 2–4 GB tier has no vision model.
22+
const MVP_ALLOWED_VISION_MODELS: &[&str] = &[""];
23+
24+
/// Embedding models allowed in MVP (2–4 GB tier uses all-minilm).
25+
const MVP_ALLOWED_EMBEDDING_MODELS: &[&str] = &["all-minilm:latest"];
26+
27+
fn enforce_mvp_chat_allowlist(resolved: &str) -> String {
28+
let lower = resolved.to_ascii_lowercase();
29+
for allowed in MVP_ALLOWED_CHAT_MODELS {
30+
if lower == allowed.to_ascii_lowercase() {
31+
return resolved.to_string();
32+
}
33+
}
34+
tracing::warn!(
35+
resolved,
36+
fallback = MVP_DEFAULT_CHAT_MODEL,
37+
"[local_ai] chat model not in MVP allowlist, redirecting to default"
38+
);
39+
MVP_DEFAULT_CHAT_MODEL.to_string()
40+
}
41+
42+
fn enforce_mvp_vision_allowlist(resolved: &str) -> String {
43+
let lower = resolved.to_ascii_lowercase();
44+
for allowed in MVP_ALLOWED_VISION_MODELS {
45+
if lower == allowed.to_ascii_lowercase() {
46+
return resolved.to_string();
47+
}
48+
}
49+
tracing::warn!(
50+
resolved,
51+
"[local_ai] vision model not in MVP allowlist, disabling vision"
52+
);
53+
String::new()
54+
}
55+
56+
fn enforce_mvp_embedding_allowlist(resolved: &str) -> String {
57+
let lower = resolved.to_ascii_lowercase();
58+
for allowed in MVP_ALLOWED_EMBEDDING_MODELS {
59+
if lower == allowed.to_ascii_lowercase() {
60+
return resolved.to_string();
61+
}
62+
}
63+
tracing::warn!(
64+
resolved,
65+
fallback = MVP_ALLOWED_EMBEDDING_MODELS[0],
66+
"[local_ai] embedding model not in MVP allowlist, redirecting to default"
67+
);
68+
MVP_ALLOWED_EMBEDDING_MODELS[0].to_string()
69+
}
70+
1071
pub(crate) fn effective_chat_model_id(config: &Config) -> String {
1172
let raw = if !config.local_ai.chat_model_id.trim().is_empty() {
1273
config.local_ai.chat_model_id.trim()
1374
} else {
1475
config.local_ai.model_id.trim()
1576
};
1677
if raw.is_empty() {
17-
return DEFAULT_OLLAMA_MODEL.to_string();
78+
return enforce_mvp_chat_allowlist(DEFAULT_OLLAMA_MODEL);
1879
}
1980
let lower = raw.to_ascii_lowercase();
2081
if lower.ends_with(".gguf")
2182
|| lower.contains("huggingface.co/")
2283
|| lower == "qwen3-1.7b"
2384
|| lower == "qwen2.5-1.5b-instruct"
2485
{
25-
return DEFAULT_OLLAMA_MODEL.to_string();
86+
return enforce_mvp_chat_allowlist(DEFAULT_OLLAMA_MODEL);
2687
}
27-
raw.to_string()
88+
enforce_mvp_chat_allowlist(raw)
2889
}
2990

3091
pub(crate) fn effective_vision_model_id(config: &Config) -> String {
@@ -33,18 +94,20 @@ pub(crate) fn effective_vision_model_id(config: &Config) -> String {
3394
return String::new();
3495
}
3596
let lower = raw.to_ascii_lowercase();
36-
if lower == "moondream:1.8b" || lower == "moondream" {
37-
return DEFAULT_LOW_VISION_MODEL.to_string();
38-
}
39-
raw.to_string()
97+
let resolved = if lower == "moondream:1.8b" || lower == "moondream" {
98+
DEFAULT_LOW_VISION_MODEL
99+
} else {
100+
raw
101+
};
102+
enforce_mvp_vision_allowlist(resolved)
40103
}
41104

42105
pub(crate) fn effective_embedding_model_id(config: &Config) -> String {
43106
let raw = config.local_ai.embedding_model_id.trim();
44107
if raw.is_empty() {
45-
return DEFAULT_OLLAMA_EMBED_MODEL.to_string();
108+
return enforce_mvp_embedding_allowlist(DEFAULT_OLLAMA_EMBED_MODEL);
46109
}
47-
raw.to_string()
110+
enforce_mvp_embedding_allowlist(raw)
48111
}
49112

50113
pub(crate) fn effective_stt_model_id(config: &Config) -> String {
@@ -88,21 +151,34 @@ mod tests {
88151

89152
config.local_ai.chat_model_id = String::new();
90153
config.local_ai.model_id = String::new();
91-
assert_eq!(effective_chat_model_id(&config), DEFAULT_OLLAMA_MODEL);
154+
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
92155

93156
config.local_ai.chat_model_id = "custom.gguf".to_string();
94-
assert_eq!(effective_chat_model_id(&config), DEFAULT_OLLAMA_MODEL);
157+
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
95158

96159
config.local_ai.chat_model_id = "qwen3-1.7b".to_string();
97-
assert_eq!(effective_chat_model_id(&config), DEFAULT_OLLAMA_MODEL);
160+
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
98161
}
99162

100163
#[test]
101-
fn chat_model_prefers_explicit_supported_chat_model() {
164+
fn chat_model_allows_mvp_model() {
102165
let mut config = test_config();
103-
config.local_ai.model_id = "fallback:model".to_string();
166+
config.local_ai.chat_model_id = "gemma3:1b-it-qat".to_string();
167+
assert_eq!(effective_chat_model_id(&config), "gemma3:1b-it-qat");
168+
}
169+
170+
#[test]
171+
fn chat_model_rejects_non_mvp_models() {
172+
let mut config = test_config();
173+
// All models outside the single MVP-allowed model are rejected.
104174
config.local_ai.chat_model_id = "gemma3:4b-it-qat".to_string();
105-
assert_eq!(effective_chat_model_id(&config), "gemma3:4b-it-qat");
175+
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
176+
177+
config.local_ai.chat_model_id = "gemma3:270m-it-qat".to_string();
178+
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
179+
180+
config.local_ai.chat_model_id = "gemma4:e4b".to_string();
181+
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
106182
}
107183

108184
#[test]
@@ -111,10 +187,12 @@ mod tests {
111187
config.local_ai.vision_model_id = String::new();
112188
assert_eq!(effective_vision_model_id(&config), "");
113189

190+
// Moondream is not in the MVP vision allowlist (only "" is allowed),
191+
// so it gets redirected to "" (vision disabled).
114192
config.local_ai.vision_model_id = "moondream".to_string();
115-
assert_eq!(effective_vision_model_id(&config), DEFAULT_LOW_VISION_MODEL);
193+
assert_eq!(effective_vision_model_id(&config), "");
116194
config.local_ai.vision_model_id = "moondream:1.8b".to_string();
117-
assert_eq!(effective_vision_model_id(&config), DEFAULT_LOW_VISION_MODEL);
195+
assert_eq!(effective_vision_model_id(&config), "");
118196
}
119197

120198
#[test]

0 commit comments

Comments
 (0)