ltcai 4.4.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +77 -33
  2. package/docs/CHANGELOG.md +128 -0
  3. package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +49 -0
  4. package/docs/V4_5_0_GRAPH_UX_REPORT.md +34 -0
  5. package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +40 -0
  6. package/docs/V4_5_0_ONBOARDING_REPORT.md +31 -0
  7. package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +49 -0
  8. package/docs/V4_5_0_VALIDATION_REPORT.md +60 -0
  9. package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +33 -0
  10. package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +37 -0
  11. package/docs/V4_5_1_NAVIGATION_REPORT.md +37 -0
  12. package/docs/V4_5_1_ONBOARDING_REPORT.md +29 -0
  13. package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +61 -0
  14. package/docs/V4_5_1_RC_ARTIFACTS.md +44 -0
  15. package/docs/V4_5_1_UX_REPORT.md +45 -0
  16. package/docs/V4_5_1_VALIDATION_REPORT.md +54 -0
  17. package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +30 -0
  18. package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +58 -0
  19. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +18 -17
  20. package/docs/architecture.md +8 -4
  21. package/frontend/index.html +2 -2
  22. package/frontend/src/App.tsx +120 -98
  23. package/frontend/src/api/client.ts +84 -1
  24. package/frontend/src/components/BrainConversation.tsx +301 -0
  25. package/frontend/src/components/FirstRunGuide.tsx +99 -0
  26. package/frontend/src/components/LivingBrain.tsx +121 -0
  27. package/frontend/src/components/ProductFlow.tsx +596 -0
  28. package/frontend/src/components/primitives.tsx +131 -25
  29. package/frontend/src/components/ui/badge.tsx +2 -2
  30. package/frontend/src/components/ui/button.tsx +7 -7
  31. package/frontend/src/components/ui/card.tsx +5 -5
  32. package/frontend/src/components/ui/input.tsx +1 -1
  33. package/frontend/src/components/ui/textarea.tsx +1 -1
  34. package/frontend/src/pages/Act.tsx +58 -28
  35. package/frontend/src/pages/Ask.tsx +2 -197
  36. package/frontend/src/pages/Brain.tsx +108 -71
  37. package/frontend/src/pages/Capture.tsx +24 -24
  38. package/frontend/src/pages/Library.tsx +222 -32
  39. package/frontend/src/pages/System.tsx +56 -34
  40. package/frontend/src/routes.ts +16 -25
  41. package/frontend/src/store/appStore.ts +8 -1
  42. package/frontend/src/styles.css +1663 -36
  43. package/lattice_brain/__init__.py +1 -1
  44. package/lattice_brain/runtime/multi_agent.py +1 -1
  45. package/latticeai/__init__.py +1 -1
  46. package/latticeai/api/models.py +107 -18
  47. package/latticeai/core/marketplace.py +1 -1
  48. package/latticeai/core/model_compat.py +250 -0
  49. package/latticeai/core/workspace_os.py +1 -1
  50. package/latticeai/models/router.py +136 -32
  51. package/latticeai/services/model_catalog.py +2 -2
  52. package/latticeai/services/model_recommendation.py +8 -1
  53. package/latticeai/services/model_runtime.py +18 -3
  54. package/package.json +2 -2
  55. package/scripts/build_frontend_assets.mjs +12 -1
  56. package/src-tauri/Cargo.lock +1 -1
  57. package/src-tauri/Cargo.toml +1 -1
  58. package/src-tauri/tauri.conf.json +1 -1
  59. package/static/app/asset-manifest.json +5 -5
  60. package/static/app/assets/index-By-G-Kay.css +2 -0
  61. package/static/app/assets/index-CJx6WuQH.js +336 -0
  62. package/static/app/assets/index-CJx6WuQH.js.map +1 -0
  63. package/static/app/index.html +4 -4
  64. package/static/manifest.json +1 -1
  65. package/static/app/assets/index-CHHal8Zl.css +0 -2
  66. package/static/app/assets/index-pdzil9ac.js +0 -333
  67. package/static/app/assets/index-pdzil9ac.js.map +0 -1
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "4.4.0"
29
+ __version__ = "4.6.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -14,7 +14,7 @@ from datetime import datetime
14
14
  from typing import Any, Callable, Dict, List, Optional
15
15
 
16
16
 
17
- MULTI_AGENT_VERSION = "4.4.0"
17
+ MULTI_AGENT_VERSION = "4.6.0"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "4.4.0"
3
+ __version__ = "4.6.0"
@@ -135,6 +135,8 @@ def create_models_router(
135
135
  loaded_ids: Optional[List[str]] = None,
136
136
  current_id: Optional[str] = None,
137
137
  ) -> List[Dict[str, object]]:
138
+ from latticeai.core.model_compat import model_runtime_compatibility
139
+
138
140
  engine_lookup = {str(engine.get("id") or ""): engine for engine in engines or []}
139
141
  model_lookup: Dict[str, Dict[str, object]] = {}
140
142
  for engine in engines or []:
@@ -147,30 +149,93 @@ def create_models_router(
147
149
  for item in items:
148
150
  short_id = str(item["id"]).lower()
149
151
  aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
150
- options: List[Dict[str, str]] = []
152
+ options: List[Dict[str, object]] = []
151
153
  for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
152
154
  real = aliases.get(engine_name)
153
155
  if not real:
154
156
  continue
157
+ load_id = real if engine_name == "local_mlx" else f"{engine_name}:{real}"
158
+ engine_info = engine_lookup.get(engine_name) or {}
159
+ model_info = model_lookup.get(load_id) or model_lookup.get(real) or {}
160
+ option_loaded = load_id in loaded or real in loaded or current_id in {load_id, real}
161
+ option_runtime = model_runtime_compatibility(load_id, engine=engine_name)
162
+ option_supported = option_runtime.get("supported") is not False
163
+ option_pulled = bool(model_info.get("pulled"))
164
+ option_download_required = bool(item.get("pullable", True) and not option_pulled and not option_loaded)
155
165
  options.append({
156
166
  "engine": engine_name,
157
167
  "model_id": real,
158
- "load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
168
+ "load_id": load_id,
169
+ "installed": bool(engine_info.get("installed")),
170
+ "pulled": option_pulled,
171
+ "loaded": option_loaded,
172
+ "download_required": option_download_required,
173
+ "runtime_compatibility": option_runtime,
174
+ "runtime_supported": option_supported,
175
+ "runtime_label": str(option_runtime.get("preferred_runtime") or engine_info.get("name") or engine_name),
159
176
  })
160
177
  if not options:
161
- options.append({"engine": "local_mlx", "model_id": item["id"], "load_id": item["id"]})
162
- recommended_engine = options[0]["engine"]
163
- load_id = options[0]["load_id"]
164
- engine_info = engine_lookup.get(recommended_engine) or {}
165
- model_info = model_lookup.get(load_id) or model_lookup.get(str(item["id"])) or {}
166
- pulled = bool(model_info.get("pulled"))
167
- is_loaded = load_id in loaded or str(item["id"]) in loaded or current_id in {load_id, str(item["id"])}
168
- engine_installed = bool(engine_info.get("installed"))
178
+ raw_id = str(item["id"])
179
+ engine_info = engine_lookup.get("local_mlx") or {}
180
+ model_info = model_lookup.get(raw_id) or {}
181
+ option_loaded = raw_id in loaded or current_id == raw_id
182
+ option_pulled = bool(model_info.get("pulled"))
183
+ runtime_compatibility = model_runtime_compatibility(str(item["id"]), engine="local_mlx")
184
+ options.append({
185
+ "engine": "local_mlx",
186
+ "model_id": item["id"],
187
+ "load_id": item["id"],
188
+ "installed": bool(engine_info.get("installed")),
189
+ "pulled": option_pulled,
190
+ "loaded": option_loaded,
191
+ "download_required": bool(item.get("pullable", True) and not option_pulled and not option_loaded),
192
+ "runtime_compatibility": runtime_compatibility,
193
+ "runtime_supported": runtime_compatibility.get("supported") is not False,
194
+ "runtime_label": str(runtime_compatibility.get("preferred_runtime") or "MLX"),
195
+ })
196
+
197
+ def option_rank(option: Dict[str, object]) -> tuple[int, int, int, int]:
198
+ runtime_supported = bool(option.get("runtime_supported"))
199
+ installed = bool(option.get("installed"))
200
+ loaded_option = bool(option.get("loaded"))
201
+ ready_without_download = installed and not bool(option.get("download_required"))
202
+ return (
203
+ 0 if runtime_supported else 1,
204
+ 0 if loaded_option or ready_without_download else 1,
205
+ 0 if installed else 1,
206
+ ["local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"].index(str(option.get("engine") or "vllm")),
207
+ )
208
+
209
+ primary_option = options[0]
210
+ primary_compatibility = dict(primary_option.get("runtime_compatibility") or {})
211
+ hard_primary_statuses = {
212
+ "runtime_update_needed",
213
+ "unsupported_format",
214
+ "repair_model",
215
+ "incomplete_download",
216
+ }
217
+ selected_option = (
218
+ primary_option
219
+ if primary_compatibility.get("supported") is False
220
+ and primary_compatibility.get("status") in hard_primary_statuses
221
+ else min(options, key=option_rank)
222
+ )
223
+ recommended_engine = str(selected_option["engine"])
224
+ load_id = str(selected_option["load_id"])
225
+ model_info = model_lookup.get(load_id) or model_lookup.get(str(selected_option.get("model_id") or "")) or {}
226
+ pulled = bool(selected_option.get("pulled") or model_info.get("pulled"))
227
+ is_loaded = bool(selected_option.get("loaded"))
228
+ engine_installed = bool(selected_option.get("installed"))
169
229
  pullable = bool(item.get("pullable", True))
170
- download_required = bool(pullable and not pulled and not is_loaded)
230
+ runtime_compatibility = dict(selected_option.get("runtime_compatibility") or {})
231
+ runtime_supported = runtime_compatibility.get("supported") is not False
232
+ download_required = bool(selected_option.get("download_required") and pullable and not is_loaded)
171
233
  if is_loaded:
172
234
  load_status = "loaded"
173
235
  unavailable_reason = None
236
+ elif not runtime_supported:
237
+ load_status = str(runtime_compatibility.get("status") or "unsupported")
238
+ unavailable_reason = str(runtime_compatibility.get("user_message") or "This model is not supported by the installed runtime.")
174
239
  elif not engine_installed:
175
240
  load_status = "unavailable"
176
241
  unavailable_reason = f"{engine_info.get('name') or recommended_engine} runtime is not installed."
@@ -196,9 +261,13 @@ def create_models_router(
196
261
  "source_display_order": item.get("source_display_order"),
197
262
  "pulled": pulled,
198
263
  "download_required": download_required,
199
- "load_available": is_loaded or (engine_installed and not download_required),
264
+ "load_available": is_loaded or (runtime_supported and engine_installed and not download_required),
200
265
  "load_status": load_status,
201
266
  "unavailable_reason": unavailable_reason,
267
+ "runtime_compatibility": runtime_compatibility,
268
+ "recovery_guidance": runtime_compatibility.get("recovery_guidance") or [],
269
+ "alternative_recommendations": runtime_compatibility.get("alternatives") or [],
270
+ "runtime_label": selected_option.get("runtime_label"),
202
271
  }
203
272
  base["engine_options"] = options
204
273
  base["recommended_engine"] = recommended_engine
@@ -272,10 +341,20 @@ def create_models_router(
272
341
  @router.post("/engines/prepare-model")
273
342
  async def engines_prepare_model(req: PrepareModelRequest, request: Request):
274
343
  require_user(request)
275
- return await prepare_and_load_model(
276
- req.model, request, engine=req.engine, user_email=req.user_email,
277
- allow_download=req.allow_download,
278
- )
344
+ try:
345
+ return await prepare_and_load_model(
346
+ req.model, request, engine=req.engine, user_email=req.user_email,
347
+ allow_download=req.allow_download,
348
+ )
349
+ except HTTPException:
350
+ raise
351
+ except Exception as exc:
352
+ from latticeai.core.model_compat import friendly_model_runtime_error
353
+
354
+ raise HTTPException(
355
+ status_code=500,
356
+ detail=friendly_model_runtime_error(exc, model_id=req.model, engine=req.engine),
357
+ )
279
358
 
280
359
  @router.post("/engines/prepare-model/stream")
281
360
  async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
@@ -295,9 +374,11 @@ def create_models_router(
295
374
  })
296
375
  except Exception as exc:
297
376
  logging.exception("model prepare stream failed")
377
+ from latticeai.core.model_compat import friendly_model_runtime_error
378
+
298
379
  yield sse_event("error", {
299
380
  "status_code": 500,
300
- "detail": str(exc)[-1000:] or "모델 준비에 실패했습니다.",
381
+ "detail": friendly_model_runtime_error(exc, model_id=req.model, engine=req.engine),
301
382
  })
302
383
 
303
384
  return StreamingResponse(
@@ -356,6 +437,8 @@ def create_models_router(
356
437
  @router.post("/models/load")
357
438
  async def load_model(req: LoadModelRequest, request: Request):
358
439
  try:
440
+ from latticeai.core.model_compat import friendly_model_runtime_error, model_runtime_compatibility
441
+
359
442
  model_id = req.model_id
360
443
  requested_engine = req.engine or (model_id.split(":", 1)[0] if ":" in model_id else "local_mlx")
361
444
  if IS_PUBLIC_MODE and not ALLOW_LOCAL_MODELS and requested_engine in {"local_mlx", "mlx"}:
@@ -363,6 +446,9 @@ def create_models_router(
363
446
  status_code=400,
364
447
  detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
365
448
  )
449
+ compatibility = model_runtime_compatibility(model_id, engine=requested_engine)
450
+ if compatibility.get("supported") is False:
451
+ raise HTTPException(status_code=400, detail=compatibility)
366
452
  return await prepare_and_load_model(
367
453
  model_id, request, engine=req.engine, user_email=req.user_email,
368
454
  adapter_path=req.adapter_path, draft_model_id=req.draft_model_id,
@@ -371,7 +457,10 @@ def create_models_router(
371
457
  except HTTPException:
372
458
  raise
373
459
  except Exception as e:
374
- raise HTTPException(status_code=500, detail=str(e))
460
+ raise HTTPException(
461
+ status_code=500,
462
+ detail=friendly_model_runtime_error(e, model_id=req.model_id, engine=req.engine),
463
+ )
375
464
 
376
465
  @router.post("/models/switch/{model_id:path}")
377
466
  async def switch_model(model_id: str, request: Request):
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "4.4.0"
14
+ MARKETPLACE_VERSION = "4.6.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -12,11 +12,14 @@
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import importlib.util
16
+ import json
15
17
  import logging
16
18
  import re
17
19
  import threading
18
20
  import time
19
21
  from dataclasses import dataclass, asdict
22
+ from pathlib import Path
20
23
  from typing import Any, Dict, List, Optional, Tuple
21
24
 
22
25
  logger = logging.getLogger(__name__)
@@ -119,6 +122,251 @@ def get_model_profile(model_id: str, engine: Optional[str] = None) -> Dict[str,
119
122
  return base
120
123
 
121
124
 
125
+ # ── Runtime compatibility checks ─────────────────────────────────────────────
126
+
127
+ GEMMA4_MLX_UNIFIED_MODULE = "mlx_vlm.models.gemma4_unified"
128
+ GEMMA4_MLX_LM_MODULES = ("mlx_lm.models.gemma4", "mlx_lm.models.gemma4_text")
129
+ GEMMA4_UNIFIED_ID_PATTERN = re.compile(r"gemma[-_/ ]?4[-_/ ]?12b", re.I)
130
+
131
+
132
+ def _module_available(module: str) -> bool:
133
+ try:
134
+ return importlib.util.find_spec(module) is not None
135
+ except (ImportError, ModuleNotFoundError, AttributeError, ValueError):
136
+ return False
137
+
138
+
139
+ def _is_gemma4(model_id: str) -> bool:
140
+ raw = str(model_id or "").lower()
141
+ return bool(re.search(r"gemma[-_/ ]?4", raw))
142
+
143
+
144
+ def _hf_model_dir(repo_id: str) -> Path:
145
+ return Path.home() / ".ltcai" / "hf-models" / repo_id.replace("/", "__")
146
+
147
+
148
+ def _local_model_type(model_id: str) -> Optional[str]:
149
+ raw = str(model_id or "").strip()
150
+ if "gemma4_unified" in raw.lower():
151
+ return "gemma4_unified"
152
+ candidates = []
153
+ explicit = Path(raw).expanduser()
154
+ if raw and explicit.exists():
155
+ candidates.append(explicit / "config.json")
156
+ candidates.append(_hf_model_dir(raw) / "config.json")
157
+ for config_path in candidates:
158
+ try:
159
+ if config_path.exists():
160
+ data = json.loads(config_path.read_text(encoding="utf-8"))
161
+ model_type = str(data.get("model_type") or "").strip().lower()
162
+ if model_type:
163
+ return model_type
164
+ except Exception:
165
+ logger.debug("failed to read model config %s", config_path, exc_info=True)
166
+ if GEMMA4_UNIFIED_ID_PATTERN.search(raw):
167
+ return "gemma4_unified"
168
+ return None
169
+
170
+
171
+ def _gemma4_runtime_candidates(raw_model_id: str) -> List[Dict[str, Any]]:
172
+ mlx_available = _module_available("mlx")
173
+ mlx_vlm_available = mlx_available and _module_available("mlx_vlm")
174
+ mlx_vlm_unified_available = mlx_vlm_available and _module_available(GEMMA4_MLX_UNIFIED_MODULE)
175
+ mlx_lm_available = mlx_available and _module_available("mlx_lm")
176
+ mlx_lm_gemma4_available = mlx_lm_available and any(_module_available(module) for module in GEMMA4_MLX_LM_MODULES)
177
+ return [
178
+ {
179
+ "engine": "local_mlx",
180
+ "runtime": "MLX-VLM",
181
+ "load_id": raw_model_id,
182
+ "available": mlx_vlm_available,
183
+ "supports_gemma4_unified": mlx_vlm_unified_available,
184
+ "role": "v3_primary",
185
+ },
186
+ {
187
+ "engine": "local_mlx",
188
+ "runtime": "MLX-LM",
189
+ "load_id": raw_model_id,
190
+ "available": mlx_lm_gemma4_available,
191
+ "role": "v3_text_fallback",
192
+ },
193
+ {
194
+ "engine": "ollama",
195
+ "runtime": "Ollama GGUF",
196
+ "load_id": "ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M",
197
+ "available": None,
198
+ "role": "gguf_local_server",
199
+ },
200
+ {
201
+ "engine": "lmstudio",
202
+ "runtime": "LM Studio GGUF",
203
+ "load_id": "lmstudio:ggml-org/gemma-4-12B-it-GGUF",
204
+ "available": None,
205
+ "role": "gguf_local_server",
206
+ },
207
+ {
208
+ "engine": "llamacpp",
209
+ "runtime": "llama.cpp GGUF",
210
+ "load_id": "llamacpp:ggml-org/gemma-4-12B-it-GGUF",
211
+ "available": None,
212
+ "role": "gguf_local_server",
213
+ },
214
+ ]
215
+
216
+
217
+ def model_runtime_compatibility(model_id: str, engine: Optional[str] = None) -> Dict[str, Any]:
218
+ """Return a lightweight pre-load runtime compatibility signal.
219
+
220
+ This intentionally checks only known fast failure modes. The loader and
221
+ smoke test remain the final authority, but the UI must not present a model
222
+ as ready when the installed runtime is known to lack its required loader.
223
+ """
224
+ normalized_engine = (engine or "").strip().lower()
225
+ if normalized_engine in {"", "mlx"}:
226
+ normalized_engine = "local_mlx"
227
+ raw_model_id = str(model_id or "")
228
+ if raw_model_id.startswith(("local_mlx:", "mlx:")):
229
+ raw_model_id = raw_model_id.split(":", 1)[1]
230
+
231
+ payload: Dict[str, Any] = {
232
+ "model_id": raw_model_id,
233
+ "engine": normalized_engine or None,
234
+ "family": detect_model_family(raw_model_id),
235
+ "status": "supported",
236
+ "supported": True,
237
+ "checked": True,
238
+ "runtime": None,
239
+ "preferred_runtime": None,
240
+ "runtime_candidates": [],
241
+ "missing_components": [],
242
+ "user_message": None,
243
+ "recovery_guidance": [],
244
+ "alternatives": [],
245
+ }
246
+
247
+ if normalized_engine != "local_mlx" or not _is_gemma4(raw_model_id):
248
+ return payload
249
+
250
+ candidates = _gemma4_runtime_candidates(raw_model_id)
251
+ payload["runtime_candidates"] = candidates
252
+ payload["runtime"] = "MLX-VLM"
253
+ payload["preferred_runtime"] = "MLX-VLM"
254
+ model_type = _local_model_type(raw_model_id)
255
+ if model_type:
256
+ payload["model_type"] = model_type
257
+
258
+ mlx_available = _module_available("mlx")
259
+ mlx_vlm_available = _module_available("mlx_vlm")
260
+ mlx_lm_available = any(bool(candidate.get("available")) for candidate in candidates if candidate.get("runtime") == "MLX-LM")
261
+
262
+ if not mlx_available or not (mlx_vlm_available or mlx_lm_available):
263
+ payload.update({
264
+ "status": "runtime_not_installed",
265
+ "checked": False,
266
+ "supported": True,
267
+ "user_message": (
268
+ "Install the local MLX runtime before loading Gemma 4, or choose "
269
+ "the Gemma 4 GGUF route through Ollama, LM Studio, or llama.cpp."
270
+ ),
271
+ "alternatives": candidates[2:],
272
+ })
273
+ return payload
274
+
275
+ if model_type == "gemma4_unified" and not _module_available(GEMMA4_MLX_UNIFIED_MODULE):
276
+ payload.update({
277
+ "status": "runtime_update_needed",
278
+ "supported": False,
279
+ "reason_code": "mlx_vlm_missing_gemma4_unified_model",
280
+ "model_type": "gemma4_unified",
281
+ "missing_components": [GEMMA4_MLX_UNIFIED_MODULE],
282
+ "user_message": (
283
+ "Gemma 4 12B uses the gemma4_unified MLX format. The installed "
284
+ "MLX-VLM runtime does not include that loader, so this local "
285
+ "model cannot load until MLX-VLM is updated."
286
+ ),
287
+ "recovery_guidance": [
288
+ "Update the MLX runtime from Library/System setup, or run: pip install --upgrade 'mlx-vlm>=0.6.3'.",
289
+ "After the runtime update, re-open Models so Lattice can re-check this model.",
290
+ "Use Gemma 4 26B A4B locally or Gemma 4 12B GGUF through Ollama, LM Studio, or llama.cpp until then.",
291
+ ],
292
+ "alternatives": [
293
+ {"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B", "engine": "local_mlx"},
294
+ {"id": "ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M", "name": "Gemma 4 12B GGUF", "engine": "ollama"},
295
+ {"id": "lmstudio:ggml-org/gemma-4-12B-it-GGUF", "name": "Gemma 4 12B GGUF", "engine": "lmstudio"},
296
+ ],
297
+ "action": "Runtime update needed",
298
+ })
299
+ return payload
300
+
301
+ if mlx_vlm_available:
302
+ return payload
303
+
304
+ payload.update({
305
+ "status": "fallback_available",
306
+ "supported": True,
307
+ "reason_code": "mlx_vlm_missing_gemma4_standard_runtime",
308
+ "missing_components": ["mlx_vlm"],
309
+ "user_message": (
310
+ "MLX-VLM is not available for this Gemma 4 model. Lattice can use "
311
+ "the MLX-LM text fallback or a Gemma 4 GGUF local runtime."
312
+ ),
313
+ "recovery_guidance": [
314
+ "Use the MLX-LM text fallback for text chat.",
315
+ "Use a Gemma 4 GGUF model through Ollama, LM Studio, or llama.cpp if the MLX route fails.",
316
+ ],
317
+ "alternatives": [
318
+ {"id": candidate["load_id"], "name": candidate["runtime"], "engine": candidate["engine"]}
319
+ for candidate in candidates
320
+ if candidate.get("role") != "v3_primary"
321
+ ],
322
+ })
323
+ if mlx_lm_available:
324
+ payload["preferred_runtime"] = "MLX-LM fallback"
325
+ return payload
326
+
327
+
328
+ def friendly_model_runtime_error(
329
+ error: BaseException | str,
330
+ *,
331
+ model_id: Optional[str] = None,
332
+ engine: Optional[str] = None,
333
+ ) -> Dict[str, Any]:
334
+ """Convert loader exceptions into end-user recoverable error payloads."""
335
+ raw = str(error or "")
336
+ compat = model_runtime_compatibility(model_id or raw, engine=engine)
337
+ if not compat.get("supported", True):
338
+ return {
339
+ "status": compat.get("status") or "unsupported",
340
+ "model_id": model_id,
341
+ "engine": engine,
342
+ "user_message": compat.get("user_message") or (
343
+ "The selected model is not supported by the installed local runtime."
344
+ ),
345
+ "recovery_guidance": compat.get("recovery_guidance") or [
346
+ "Choose a recommended alternative model.",
347
+ "Update the local runtime and try validation again.",
348
+ ],
349
+ "alternatives": compat.get("alternatives") or [],
350
+ "missing_components": compat.get("missing_components") or [],
351
+ "action": compat.get("action"),
352
+ "reason_code": compat.get("reason_code") or "runtime_model_type_unsupported",
353
+ }
354
+ return {
355
+ "status": "load_failed",
356
+ "model_id": model_id,
357
+ "engine": engine,
358
+ "user_message": (
359
+ "The model could not be loaded. Check that the runtime is installed, "
360
+ "the model files are present, and try a recommended alternative if the issue continues."
361
+ ),
362
+ "recovery_guidance": [
363
+ "Open Models and run the setup flow again.",
364
+ "Confirm downloads were explicitly allowed for models that are not on this computer.",
365
+ "Try a recommended smaller local model if memory is low.",
366
+ ],
367
+ }
368
+
369
+
122
370
  # ── Postprocessing ────────────────────────────────────────────────────────────
123
371
 
124
372
  BAD_MARKERS = [
@@ -378,7 +626,9 @@ __all__ = [
378
626
  "FAMILY_PROFILES",
379
627
  "CompatProfile",
380
628
  "detect_model_family",
629
+ "friendly_model_runtime_error",
381
630
  "get_model_profile",
631
+ "model_runtime_compatibility",
382
632
  "fast_postprocess",
383
633
  "validate_smoke_response",
384
634
  "classify_smoke_response",
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
21
 
22
- WORKSPACE_OS_VERSION = "4.4.0"
22
+ WORKSPACE_OS_VERSION = "4.6.0"
23
23
 
24
24
  # Workspace types separate single-user Personal workspaces from shared
25
25
  # Organization workspaces. Both keep the same local-first JSON store; the type