mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
* 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:
co-authored by
Claude Opus 4.6
parent
098206cea7
commit
f014058417
@@ -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}{' '}
|
||||
· Target RAM: {preset.target_ram_gb} GB
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -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 (2–4 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
|
||||
/// 2–4 GB tier has no vision model.
|
||||
const MVP_ALLOWED_VISION_MODELS: &[&str] = &[""];
|
||||
|
||||
/// Embedding models allowed in MVP (2–4 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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user