Files
jundot--omlx/omlx/model_discovery.py
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

1383 lines
50 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Model discovery for oMLX multi-model serving.
This module scans a model directory and discovers available models,
estimating memory usage for each.
Supports:
- LLM models: Use BatchedEngine for continuous batching with paged KV cache
- VLM models: Use VLMBatchedEngine for vision-language model inference
- Embedding models: Use EmbeddingEngine for batch embedding generation
- Reranker models: Use RerankerEngine for document reranking
- Audio STT models: Use STTEngine for speech-to-text (Whisper, Qwen3-ASR, ...)
- Audio TTS models: Use TTSEngine for text-to-speech (Qwen3-TTS, Kokoro, ...)
"""
import contextlib
import json
import logging
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
logger = logging.getLogger(__name__)
ModelType = Literal["llm", "vlm", "embedding", "reranker", "audio_stt", "audio_tts", "audio_sts"]
EngineType = Literal["batched", "vlm", "embedding", "reranker", "audio_stt", "audio_tts", "audio_sts"]
# Known VLM (Vision-Language Model) types from mlx-vlm
VLM_MODEL_TYPES = {
"qwen2_vl",
"qwen2_5_vl",
"qwen3_vl",
"qwen3_vl_moe",
"qwen3_5_moe",
"gemma3",
"gemma4",
"gemma4_unified",
"diffusion_gemma",
"llava",
"llava_next",
"llava-qwen2",
"llava_qwen2", # underscore form — matches FastVLM checkpoints on disk
"mllama",
"idefics3",
"internvl_chat",
"phi3_v",
"paligemma",
"mistral3",
"pixtral",
"molmo",
"molmo2",
"bunny_llama",
"multi_modality",
"florence2",
"deepseekocr",
"deepseekocr_2",
"dots_ocr",
"glm_ocr",
"minimax_m3_vl",
"minicpmv",
"phi4_siglip",
"phi4mm",
"youtu_vl",
}
# Text-only model families that are implemented in mlx-vlm rather than
# mlx-lm. They still use the VLM engine because that path loads mlx-vlm
# models and adapts their language model to oMLX's scheduler.
VLM_NATIVE_TEXT_MODEL_TYPES = {
"cohere2_moe",
"minimax_m3",
}
# Speculative-decoding "helper" checkpoints (dFlash / MTP / assistant drafters)
# are never meant to be served as standalone chat models. Some declare a
# distinctive top-level model_type — an ``*_assistant`` (e.g. gemma4_assistant)
# or ``*_mtp`` (e.g. qwen3_5_mtp) marker — but DFlash draft checkpoints declare
# a plain model_type (e.g. ``qwen3``) and are only distinguishable by their
# architecture name (``DFlashDraftModel``) or a drafter-only config block
# (``dflash_config``). Keep these in sync with the drafter resolution in
# engine_pool.py (~1498) and the dflash gate in engine/dflash.py when new
# drafter families are added.
HELPER_CONFIG_MODEL_TYPE_SUFFIXES = ("_assistant", "_mtp")
_HELPER_ARCH_TOKENS = ("draft", "assistant", "mtp")
_HELPER_CONFIG_KEYS = ("dflash_config",)
def is_helper_config_model_type(config_model_type: str | None) -> bool:
"""True when ``config_model_type`` marks a speculative-decoding drafter.
These are the raw top-level ``model_type`` values from a checkpoint's
config.json (e.g. ``gemma4_assistant``, ``qwen3_5_mtp``). Note this misses
DFlash drafts, whose model_type is a plain ``qwen3`` — use
:func:`is_helper_model_config` when the full config dict is available.
"""
if not isinstance(config_model_type, str) or not config_model_type:
return False
mt = config_model_type.lower()
return mt.endswith(HELPER_CONFIG_MODEL_TYPE_SUFFIXES)
def is_helper_model_config(config: dict) -> bool:
"""True when a parsed config.json marks a speculative-decoding drafter.
Catches three intrinsic signals, any of which is definitive:
an ``*_assistant`` / ``*_mtp`` model_type, a drafter architecture name
(e.g. ``DFlashDraftModel``), or a drafter-only config block
(e.g. ``dflash_config``). These are helper checkpoints backing
dFlash / MTP / assistant speculative decoding, not chat models.
"""
if not isinstance(config, dict):
return False
if is_helper_config_model_type(config.get("model_type")):
return True
if any(key in config for key in _HELPER_CONFIG_KEYS):
return True
architectures = config.get("architectures") or []
if isinstance(architectures, str):
architectures = [architectures]
elif not isinstance(architectures, list | tuple | set):
return False
for arch in architectures:
arch_lower = str(arch).lower()
if any(token in arch_lower for token in _HELPER_ARCH_TOKENS):
return True
return False
# Known VLM architectures
VLM_ARCHITECTURES = {
"LlavaForConditionalGeneration",
"LlavaNextForConditionalGeneration",
"Qwen2VLForConditionalGeneration",
"Qwen2_5_VLForConditionalGeneration",
"MllamaForConditionalGeneration",
"Gemma3ForConditionalGeneration",
"Gemma4ForConditionalGeneration",
"InternVLChatModel",
"Idefics3ForConditionalGeneration",
"PaliGemmaForConditionalGeneration",
"Phi3VForCausalLM",
"Pixtral",
"MolmoForCausalLM",
"Molmo2ForConditionalGeneration",
"LlavaQwen2ForCausalLM", # apple/FastVLM (all sizes)
"Florence2ForConditionalGeneration",
}
# Known embedding model types from mlx-embeddings
EMBEDDING_MODEL_TYPES = {
"bert",
"xlm-roberta",
"xlm_roberta",
"modernbert",
"siglip",
"colqwen2_5",
"colqwen2-5",
}
# Model types that have both embedding and LLM variants.
# These require architecture-based disambiguation via EMBEDDING_ARCHITECTURES.
AMBIGUOUS_EMBEDDING_MODEL_TYPES = {
"qwen3",
"gemma3-text",
"gemma3_text",
"lfm2",
}
# Known embedding architectures
EMBEDDING_ARCHITECTURES = {
"BertModel",
"BertForMaskedLM",
"XLMRobertaModel",
"XLMRobertaForMaskedLM",
"ModernBertModel",
"ModernBertForMaskedLM",
"Qwen3ForTextEmbedding",
"SiglipModel",
"SiglipVisionModel",
"SiglipTextModel",
}
# Supported reranker architectures
SUPPORTED_RERANKER_ARCHITECTURES = {
"ModernBertForSequenceClassification", # via mlx-embeddings
"XLMRobertaForSequenceClassification", # omlx native implementation
"JinaForRanking", # Jina v3 listwise reranker
}
# CausalLM-based reranker architectures.
# These are standard CausalLM models fine-tuned for reranking via yes/no logit scoring.
# Detected by architecture + heuristic (model name or tokenizer hints).
CAUSAL_LM_RERANKER_ARCHITECTURES = {
"Qwen3ForCausalLM",
}
# CausalLM-based embedding architectures.
# These use a standard CausalLM architecture but are fine-tuned for embeddings
# (no lm_head weights). Detected by architecture + directory name heuristic.
CAUSAL_LM_EMBEDDING_ARCHITECTURES = {
"Qwen3ForCausalLM", # Qwen3-Embedding uses CausalLM arch without lm_head
"Qwen2ForCausalLM", # jina-code-embeddings & similar; only treated as an
# embedding when the dir-name heuristic (_is_causal_lm_embedding) also
# matches, so Qwen2/Qwen2.5 chat models are unaffected.
}
# Multimodal (VLM-based) reranker architectures.
# These share an architecture with VLM chat models but are fine-tuned for
# reranking. Loaded via mlx-embeddings' model.process() API. Distinguished
# from VLM chat by directory name heuristic.
MULTIMODAL_RERANKER_ARCHITECTURES = {
"Qwen3VLForConditionalGeneration", # Qwen3-VL-Reranker
}
# Multimodal (VLM-based) embedding architectures.
# Same arch as the reranker variant; distinguished by directory name hint.
MULTIMODAL_EMBEDDING_ARCHITECTURES = {
"Qwen3VLForConditionalGeneration", # Qwen3-VL-Embedding
}
# Unsupported reranker architectures (future support)
UNSUPPORTED_RERANKER_ARCHITECTURES = {
"BertForSequenceClassification",
"Qwen3ForSequenceClassification",
}
# All known reranker architectures (for model type detection)
RERANKER_ARCHITECTURES = SUPPORTED_RERANKER_ARCHITECTURES | UNSUPPORTED_RERANKER_ARCHITECTURES
# Unsupported model types — detected and skipped during discovery.
# Only top-level config fields are checked; nested audio_config/tts_config in
# multimodal models (e.g., MiniCPM-o) won't trigger this.
# Note: "whisper" and "qwen3_tts" were previously listed here but are now
# handled as audio types (audio_stt / audio_tts) — see AUDIO_* sets below.
UNSUPPORTED_MODEL_TYPES: set[str] = set()
UNSUPPORTED_ARCHITECTURES: set[str] = set()
# ---------------------------------------------------------------------------
# Audio model detection — dynamically loaded from mlx-audio when available
# ---------------------------------------------------------------------------
#
# mlx-audio maintains MODEL_REMAPPING dicts (model_type → module directory)
# and model directories under mlx_audio/{stt,tts,sts}/models/. We read these
# at import time so oMLX automatically recognises new audio model families
# when mlx-audio is updated. Falls back to static sets when mlx-audio is
# not installed.
#
# Some base LLM model_types (qwen3, llama, …) collide with mlx-audio TTS
# model directory names because mlx-audio extends these architectures for
# audio. We exclude them so a plain Qwen3 LLM is not misdetected as TTS.
_LLM_TYPE_COLLISIONS = {"qwen3", "llama", "dense"}
def _build_audio_detection_sets():
"""Build STT/TTS/STS model-type sets from mlx-audio at import time.
Returns (stt_types, tts_types, sts_types) where each is a set of
model_type strings that should trigger audio detection.
"""
try:
from pathlib import Path as _P
import mlx_audio as _mla
_base = _P(_mla.__file__).parent
def _dir_names(subdir: str) -> set:
d = _base / subdir / "models"
if d.is_dir():
return {p.name for p in d.iterdir()
if p.is_dir() and not p.name.startswith("__")}
return set()
# TTS: MODEL_REMAPPING keys + model dir names
from mlx_audio.tts.utils import MODEL_REMAPPING as _tts_remap
tts = set(_tts_remap.keys()) | _dir_names("tts")
# STT: MODEL_REMAPPING keys + model dir names
from mlx_audio.stt.utils import MODEL_REMAPPING as _stt_remap
stt = set(_stt_remap.keys()) | _dir_names("stt")
# STS: model dir names only (no unified utils/remapping)
sts = _dir_names("sts")
# Strip base-LLM names that collide with audio model dirs
tts -= _LLM_TYPE_COLLISIONS
stt -= _LLM_TYPE_COLLISIONS
logger.debug(
"Audio detection sets loaded from mlx-audio: "
"STT=%d, TTS=%d, STS=%d", len(stt), len(tts), len(sts),
)
return stt, tts, sts
except Exception:
logger.debug("mlx-audio not available — using static audio detection sets")
# Static fallback so model discovery still works without mlx-audio
_stt = {"whisper", "qwen3_asr", "parakeet", "qwen2_audio"}
_tts = {"qwen3_tts", "kokoro", "chatterbox", "vibevoice", "vibevoice_streaming", "kugelaudio", "audiodit"}
_sts = {"deepfilternet", "mossformer2_se", "sam_audio", "lfm_audio"}
return _stt, _tts, _sts
AUDIO_STT_MODEL_TYPES, AUDIO_TTS_MODEL_TYPES, AUDIO_STS_MODEL_TYPES = (
_build_audio_detection_sets()
)
# Architecture-based detection — these are checked before model_type and
# are always static because architecture strings are stable identifiers.
AUDIO_STT_ARCHITECTURES = {
"WhisperForConditionalGeneration",
"Qwen3ASRForConditionalGeneration",
"ParakeetForCTC",
"Qwen2AudioForConditionalGeneration",
}
AUDIO_TTS_ARCHITECTURES = {
"KokoroForConditionalGeneration",
"Qwen3TTSForConditionalGeneration",
"ChatterboxForConditionalGeneration",
"VibeVoiceForConditionalGeneration",
"VibeVoiceStreamingForConditionalGenerationInference",
"KugelAudioForConditionalGeneration",
}
AUDIO_STS_ARCHITECTURES = {
"DeepFilterNetModel",
"MossFormer2SEModel",
"SAMAudio",
"LFM2AudioModel",
}
@dataclass
class DiscoveredModel:
"""Information about a discovered model."""
model_id: str # Directory name (e.g., "llama-3b")
model_path: str # Full path to model directory
model_type: ModelType # "llm", "vlm", "embedding", or "reranker"
engine_type: EngineType # "batched", "vlm", "embedding", or "reranker"
estimated_size: int # Estimated memory usage in bytes
config_model_type: str = "" # Raw model_type from config.json (e.g., "deepseekocr_2")
thinking_default: bool | None = None # True if model thinks by default, False if not, None if unknown
preserve_thinking_default: bool | None = None # True when template supports preserve_thinking (Qwen 3.6+)
model_context_length: int | None = None # Declared context length from config.json (None if unknown)
source_type: str = "local" # "local" or "hf_cache"
source_repo_id: str | None = None # HuggingFace repo id for cache-backed models
is_helper: bool = False # Speculative-decoding drafter (dFlash/Assistant/MTP)
@dataclass(frozen=True)
class HfCacheEntry:
"""Resolved HuggingFace Hub cache entry."""
snapshot_path: Path
model_id: str
source_repo_id: str
def _is_unsupported_model(model_path: Path) -> bool:
"""
Check if model is an unsupported type that should be skipped during discovery.
Audio models (STT/TTS) are NOT unsupported — they are detected as
"audio_stt" or "audio_tts" by detect_model_type() and served via
their own engine types.
Only checks top-level config fields. Multimodal models with nested
audio_config/tts_config (e.g., MiniCPM-o) are not affected.
"""
config_path = model_path / "config.json"
if not config_path.exists():
return False
try:
with open(config_path) as f:
config = json.load(f)
except (json.JSONDecodeError, IOError):
return False
architectures = config.get("architectures", [])
for arch in architectures:
if arch in UNSUPPORTED_ARCHITECTURES:
return True
model_type = config.get("model_type", "")
normalized = model_type.lower().replace("-", "_")
return normalized in UNSUPPORTED_MODEL_TYPES or model_type in UNSUPPORTED_MODEL_TYPES
def _is_causal_lm_reranker(model_path: Path) -> bool:
"""
Heuristic check for CausalLM models fine-tuned as rerankers.
CausalLM rerankers (e.g., Qwen3-Reranker) use the same architecture as
their base LLMs but are fine-tuned to output yes/no logits for relevance
scoring. We detect them by checking the model directory name for "reranker"
or "rerank" keywords, since config.json is identical to a standard LLM.
"""
name_lower = model_path.name.lower()
return "reranker" in name_lower or "rerank" in name_lower
def _is_causal_lm_embedding(model_path: Path) -> bool:
"""
Heuristic check for CausalLM models fine-tuned as embedding models.
CausalLM embeddings (e.g., Qwen3-Embedding) use the same architecture as
their base LLMs but are fine-tuned for embeddings and ship without lm_head
weights. We detect them by checking the model directory name for "embedding"
or "embed" keywords, since config.json is identical to a standard LLM.
"""
name_lower = model_path.name.lower()
return "embedding" in name_lower or "embed" in name_lower
def _has_sentence_transformers_embedding_pipeline(model_path: Path) -> bool:
"""
Detect sentence-transformers style embedding exports via modules.json.
This allows oMLX to recognize embedding exports whose base transformer
architecture is ambiguous (for example gemma3_text) but which include
sentence-transformers pooling/normalization modules.
"""
modules_path = model_path / "modules.json"
if not modules_path.exists():
return False
try:
with open(modules_path) as f:
modules = json.load(f)
except (json.JSONDecodeError, IOError):
return False
if not isinstance(modules, list):
return False
module_types = {
module.get("type", "")
for module in modules
if isinstance(module, dict)
}
if "sentence_transformers.models.Transformer" not in module_types:
return False
return any(
module_type.startswith("sentence_transformers.models.")
and module_type != "sentence_transformers.models.Transformer"
for module_type in module_types
)
def _looks_like_kokoro_config(config: dict) -> bool:
"""Return True for Kokoro exports that omit HF ``model_type``.
mlx-community Kokoro conversions (e.g. Kokoro-82M-bf16) keep the original
Kokoro config — top-level ``istftnet`` + ``plbert`` sections and a
``vocab`` table — with no HF-style ``model_type``/``architectures``.
mlx-audio loads them fine, but oMLX must classify them as TTS during
discovery or they fall through to the LLM engine, whose loader only
matches ``model*.safetensors`` and fails with a misleading
"No safetensors found" error.
"""
if not isinstance(config, dict):
return False
return (
isinstance(config.get("istftnet"), dict)
and isinstance(config.get("plbert"), dict)
and isinstance(config.get("vocab"), dict)
)
def _looks_like_nemo_asr_config(config: dict) -> bool:
"""Return True for NeMo ASR exports that omit HF ``model_type``.
NVIDIA Parakeet TDT/CTC MLX conversions keep the original NeMo ASR
training config instead of a HuggingFace-style ``model_type`` or
``architectures`` field. mlx-audio can load these models by name, but
oMLX must still classify them as STT during discovery or they fall through
to the LLM engine and fail with a misleading ``'model_type'`` error.
Only top-level NeMo ASR module targets are considered so multimodal models
with nested ``audio_config`` sections are not misclassified.
"""
if not isinstance(config, dict):
return False
module_targets: list[str] = []
for key in ("preprocessor", "encoder", "decoder", "joint"):
value = config.get(key)
if isinstance(value, dict):
target = value.get("_target_")
if isinstance(target, str):
module_targets.append(target.lower())
if not any("nemo.collections.asr" in target for target in module_targets):
return False
# NeMo ASR configs include an audio preprocessor plus tokenizer/decoder
# metadata. Requiring these keeps the heuristic narrow while covering
# Parakeet TDT exports whose config has no model_type at all.
preprocessor = config.get("preprocessor")
has_audio_preprocessor = isinstance(preprocessor, dict) and (
"audio" in str(preprocessor.get("_target_", "")).lower()
or "melspectrogram" in str(preprocessor.get("_target_", "")).lower()
)
has_asr_head = isinstance(config.get("decoder"), dict) or isinstance(
config.get("joint"), dict
)
has_tokenizer = isinstance(config.get("tokenizer"), dict)
return has_audio_preprocessor and has_asr_head and has_tokenizer
def _has_vision_subconfig(config: dict) -> bool:
"""
Return True if ``config`` carries evidence of a vision sub-config.
Three keys cover the conventions in the wild:
- ``vision_config`` — most VLMs (Qwen2-VL, Gemma3, LLaVA-Next, ...).
- ``vit_config`` — Molmo / Molmo2 family.
- ``mm_vision_tower`` — older LLaVA family including FastVLM's
``llava_qwen2``. The check is non-empty-only: a config-stub text-only
quant could in principle declare a tower path it doesn't ship weights
for, but in practice bf16 FastVLM ships a real path string.
Used by the VLM classifier in :func:`detect_model_type` and by other
paths (``oq``, admin model info) that need to ask "is this a VLM?".
"""
return (
"vision_config" in config
or "vit_config" in config
or bool(config.get("mm_vision_tower"))
)
def _architecture_indicates_causal_lm(architectures: list[str]) -> bool:
"""True when ``architectures`` describe a text causal LM (not mlx-audio STS).
Liquid LFM text checkpoints (LFM2, LFM2.5 MoE, etc.) use ``lfm*`` model
types and ``*ForCausalLM`` classes. mlx-audio LFM STS uses ``LFM2AudioModel``
and is handled earlier via :data:`AUDIO_STS_ARCHITECTURES`.
"""
return any("causallm" in arch.lower() for arch in architectures)
def detect_model_type(model_path: Path) -> ModelType:
"""
Detect model type from config.json.
Checks:
1. architectures field for reranker-specific classes (SequenceClassification)
2. CausalLM-based reranker/embedding detection (architecture + directory name)
3. sentence-transformers pipeline detection via modules.json
4. architectures field for embedding-specific classes
5. model_type field against known embedding types (unambiguous only)
6. VLM detection via architectures, model_type, or vision sub-config
presence (``vision_config`` / ``vit_config`` / non-empty
``mm_vision_tower`` — see :func:`_has_vision_subconfig`)
7. Audio model detection (STT/TTS/STS)
Args:
model_path: Path to model directory
Returns:
Model type: "llm", "vlm", "embedding", "reranker", "audio_stt", "audio_tts", or "audio_sts"
"""
config_path = model_path / "config.json"
if not config_path.exists():
return "llm"
try:
with open(config_path) as f:
config = json.load(f)
except (json.JSONDecodeError, IOError):
return "llm"
# Check architectures field for reranker first (more specific)
architectures = config.get("architectures", [])
for arch in architectures:
if arch in RERANKER_ARCHITECTURES:
return "reranker"
# Check for CausalLM-based rerankers (e.g., Qwen3-Reranker).
# These use a standard CausalLM architecture but are fine-tuned for reranking
# via yes/no logit scoring. Detected by architecture + model directory name hint.
for arch in architectures:
if arch in CAUSAL_LM_RERANKER_ARCHITECTURES:
if _is_causal_lm_reranker(model_path):
return "reranker"
# Check for CausalLM-based embeddings (e.g., Qwen3-Embedding).
# These use a standard CausalLM architecture but are fine-tuned for embeddings
# and ship without lm_head weights. Detected by architecture + directory name hint.
for arch in architectures:
if arch in CAUSAL_LM_EMBEDDING_ARCHITECTURES:
if _is_causal_lm_embedding(model_path):
return "embedding"
# Check for multimodal (VLM-based) rerankers and embeddings.
# Same architecture string as VLM chat models; distinguished by the
# directory name heuristic. Must come before VLM detection below so
# the reranker/embedding hint wins over default VLM classification.
for arch in architectures:
if arch in MULTIMODAL_RERANKER_ARCHITECTURES and _is_causal_lm_reranker(model_path):
return "reranker"
if arch in MULTIMODAL_EMBEDDING_ARCHITECTURES and _is_causal_lm_embedding(model_path):
return "embedding"
if _has_sentence_transformers_embedding_pipeline(model_path):
return "embedding"
# Check architectures field for embedding (before model_type to avoid
# false positives from ambiguous model types like qwen3, gemma3-text)
for arch in architectures:
if arch in EMBEDDING_ARCHITECTURES:
return "embedding"
# Check model_type field for unambiguous embedding types
model_type = config.get("model_type", "")
# Normalize: replace hyphens with underscores and lowercase
normalized_type = model_type.lower().replace("-", "_")
if normalized_type in EMBEDDING_MODEL_TYPES or model_type in EMBEDDING_MODEL_TYPES:
return "embedding"
# Ambiguous embedding types (have both embedding and LLM variants):
# only classified as embedding if architecture matched above
if (
normalized_type in AMBIGUOUS_EMBEDDING_MODEL_TYPES
or model_type in AMBIGUOUS_EMBEDDING_MODEL_TYPES
):
logger.info(
f"Model type '{model_type}' has both embedding and LLM variants, "
f"but architecture {architectures} is not an embedding architecture "
"— treating as LLM"
)
if normalized_type in VLM_NATIVE_TEXT_MODEL_TYPES:
logger.info(
f"{model_type} detected as mlx-vlm native text model"
)
return "vlm"
# Check for VLM: architectures field
# Some text-only quants (e.g., unsloth/gemma-4-31b-it-MLX-8bit) keep the VLM
# architecture name but strip vision_config and vision weights.
# For model families known to have text-only variants, require evidence
# of a vision sub-config — see :func:`_has_vision_subconfig` for the
# three keys we accept (``vision_config``, ``vit_config``,
# ``mm_vision_tower``).
for arch in architectures:
if arch in VLM_ARCHITECTURES:
if normalized_type in VLM_MODEL_TYPES and not _has_vision_subconfig(config):
logger.info(
f"Architecture '{arch}' is a VLM architecture but no "
"vision_config / vit_config / mm_vision_tower found — "
"treating as LLM (text-only quant)"
)
break
return "vlm"
# Check for VLM: model_type field (only if vision capabilities are present)
# Some model families (e.g., qwen3_5_moe) have both VLM and text-only variants.
# Text-only quants won't carry a vision sub-config. gemma4_unified and
# diffusion_gemma are exceptions: they are served by mlx-vlm regardless of
# vision_config presence in config.json.
if normalized_type in VLM_MODEL_TYPES:
if normalized_type in {"gemma4_unified", "diffusion_gemma"}:
logger.info(
f"{model_type} detected as VLM (mlx-vlm native model)"
)
return "vlm"
if _has_vision_subconfig(config):
return "vlm"
logger.info(
f"Model type '{model_type}' is in VLM_MODEL_TYPES but no "
"vision_config / vit_config / mm_vision_tower found — "
"treating as LLM (text-only quant)"
)
# Check for VLM: presence of a vision sub-config (fallback heuristic).
# Catch-all for VLMs that aren't yet listed in VLM_MODEL_TYPES.
if _has_vision_subconfig(config):
return "vlm"
# Check for audio models — architectures take priority over model_type.
# Only top-level architectures/model_type are inspected; nested audio_config
# inside multimodal models (e.g., MiniCPM-o) does not trigger this path.
#
# Architecture check first (unambiguous):
for arch in architectures:
if arch in AUDIO_STT_ARCHITECTURES:
return "audio_stt"
# NeMo ASR exports such as mlx-community/parakeet-tdt-0.6b-v3 ship a
# NeMo training config without HF model_type/architectures. They are
# still STT models and mlx-audio can load them by directory/repo name.
if _looks_like_nemo_asr_config(config):
return "audio_stt"
# Kokoro exports similarly ship a bare original config (istftnet/plbert).
if _looks_like_kokoro_config(config):
return "audio_tts"
for arch in architectures:
if arch in AUDIO_TTS_ARCHITECTURES:
return "audio_tts"
for arch in architectures:
if arch in AUDIO_STS_ARCHITECTURES:
return "audio_sts"
# model_type check (dynamically loaded from mlx-audio when available).
# Check TTS before STT because some model_type values (e.g. "vibevoice")
# appear in both sets — TTS is the more common category for these.
if normalized_type in AUDIO_TTS_MODEL_TYPES or model_type in AUDIO_TTS_MODEL_TYPES:
return "audio_tts"
if normalized_type in AUDIO_STT_MODEL_TYPES or model_type in AUDIO_STT_MODEL_TYPES:
return "audio_stt"
if normalized_type in AUDIO_STS_MODEL_TYPES or model_type in AUDIO_STS_MODEL_TYPES:
return "audio_sts"
# mlx-audio LFM STS may use an "lfm*" model_type without a known architecture
# string yet. Liquid LFM *text* checkpoints share that prefix — disambiguate
# with CausalLM architecture names (LFM2 / LFM2.5 MoE, future lfm* LMs).
if normalized_type.startswith("lfm") and normalized_type not in EMBEDDING_MODEL_TYPES:
if _architecture_indicates_causal_lm(architectures):
return "llm"
return "audio_sts"
return "llm"
def detect_thinking_default(model_path: Path) -> bool | None:
"""Detect whether a model's chat template enables thinking by default.
Inspects the Jinja chat template for ``enable_thinking`` references and
determines the default behaviour:
* **True** — model thinks by default (e.g. Qwen 3.x: only suppresses
thinking when ``enable_thinking is false``).
* **False** — model suppresses thinking by default (e.g. Gemma 4: only
enables thinking when ``enable_thinking`` is truthy,
``default(false)``).
* **None** — template does not reference ``enable_thinking`` (model has
no thinking toggle).
"""
# Try standalone Jinja file first, then tokenizer_config.json
template_text = None
jinja_path = model_path / "chat_template.jinja"
if jinja_path.exists():
with contextlib.suppress(OSError):
template_text = jinja_path.read_text(encoding="utf-8")
if template_text is None:
tc_path = model_path / "tokenizer_config.json"
if tc_path.exists():
try:
with open(tc_path) as f:
tc = json.load(f)
template_text = tc.get("chat_template")
except Exception:
pass
if not template_text or "enable_thinking" not in template_text:
return None
# Heuristic: if the template only disables thinking when explicitly
# ``enable_thinking is false``, then thinking is ON by default.
# If the template requires ``enable_thinking`` to be truthy or uses
# ``default(false)``, then thinking is OFF by default.
if "enable_thinking is false" in template_text:
return True # ON by default (Qwen pattern)
if "default(false)" in template_text or "enable_thinking)" in template_text:
return False # OFF by default (Gemma pattern)
return None
# Context-length keys, in priority order. Order mirrors HuggingFace
# Transformers conventions: ``max_position_embeddings`` is the canonical
# field for decoder-only LLMs; ``max_seq_len`` / ``seq_length`` show up on
# Llama / Mistral / Qwen forks; ``n_positions`` is the GPT-2 lineage.
_CONTEXT_LENGTH_KEYS = (
"max_position_embeddings",
"max_seq_len",
"max_seq_length",
"seq_length",
"n_positions",
)
# tokenizer_config.json's ``model_max_length`` is Transformers' fallback
# field. Transformers seeds it with ``int(1e30)`` when the tokenizer has
# no real cap, and downstream code distinguishes that sentinel from a
# real long context. Anything above ~1e18 is treated as the sentinel.
_TOKENIZER_MAX_LENGTH_SENTINEL = 10**18
def _read_model_context_length(model_path: Path) -> int | None:
"""Discover the declared context length from a model's config files.
Resolution order:
1. Top-level ``config.json`` keys (``max_position_embeddings`` first,
then the rest of :data:`_CONTEXT_LENGTH_KEYS`).
2. Nested ``text_config`` / ``language_config`` keys (used by VLM
wrappers and Qwen-style MoE configs that put the language head in
a sub-object).
3. ``tokenizer_config.json``'s ``model_max_length`` — but only when
it is a finite positive integer, since Transformers seeds it with
``int(1e30)`` as a "no cap" sentinel.
Returns:
Positive integer context length, or ``None`` when no usable
value was found in any of the above.
"""
config_path = model_path / "config.json"
if config_path.exists():
try:
with open(config_path, encoding="utf-8") as f:
model_config = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.debug(f"Failed to read config.json for {model_path}: {e}")
model_config = {}
for key in _CONTEXT_LENGTH_KEYS:
value = model_config.get(key)
if isinstance(value, int) and value > 0:
return value
for nest_key in ("text_config", "language_config"):
nested = model_config.get(nest_key)
if isinstance(nested, dict):
for key in _CONTEXT_LENGTH_KEYS:
value = nested.get(key)
if isinstance(value, int) and value > 0:
return value
tc_path = model_path / "tokenizer_config.json"
if tc_path.exists():
try:
with open(tc_path, encoding="utf-8") as f:
tc = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.debug(f"Failed to read tokenizer_config.json for {model_path}: {e}")
tc = {}
value = tc.get("model_max_length")
if isinstance(value, int) and 0 < value < _TOKENIZER_MAX_LENGTH_SENTINEL:
return value
return None
def detect_preserve_thinking(model_path: Path) -> bool | None:
"""Detect whether a model's chat template supports ``preserve_thinking``.
Qwen 3.6+ templates strip ``<think>`` blocks from historical assistant
turns by default and only keep them when ``preserve_thinking`` is true.
Stripping breaks KV prefix cache reuse, so we default to True when the
template supports this flag.
Returns:
True if the template references ``preserve_thinking`` (should be
enabled), None otherwise (template has no such flag).
"""
template_text = None
jinja_path = model_path / "chat_template.jinja"
if jinja_path.exists():
with contextlib.suppress(OSError):
template_text = jinja_path.read_text(encoding="utf-8")
if template_text is None:
tc_path = model_path / "tokenizer_config.json"
if tc_path.exists():
try:
with open(tc_path) as f:
tc = json.load(f)
template_text = tc.get("chat_template")
except Exception:
pass
if not template_text or "preserve_thinking" not in template_text:
return None
return True
def estimate_model_size(model_path: Path) -> int:
"""
Estimate model memory usage from safetensors/bin file sizes.
MLX keeps quantized weights in compressed form, so file size ≈ memory usage.
Args:
model_path: Path to model directory
Returns:
Estimated memory usage in bytes
"""
total_size = 0
# Primary: safetensors files
safetensors_files = list(model_path.glob("*.safetensors"))
for f in safetensors_files:
total_size += f.stat().st_size
# Fallback: .bin files (older PyTorch format)
if total_size == 0:
for f in model_path.glob("*.bin"):
# Filter out non-weight files
name_lower = f.name.lower()
if "optimizer" in name_lower or "training" in name_lower:
continue
total_size += f.stat().st_size
# Also check in subdirectories (some models store weights in subfolders)
if total_size == 0:
for f in model_path.glob("**/*.safetensors"):
total_size += f.stat().st_size
if total_size == 0:
raise ValueError(f"No model weights found in {model_path}")
# Add overhead for runtime buffers (~5%)
overhead_factor = 1.05
return int(total_size * overhead_factor)
def _is_adapter_dir(path: Path) -> bool:
"""Check if a directory contains a LoRA/PEFT adapter (has adapter_config.json)."""
return (path / "adapter_config.json").exists()
def _is_model_dir(path: Path) -> bool:
"""Check if a directory contains a valid model (has config.json)."""
return (path / "config.json").exists() and not _is_adapter_dir(path)
def model_directory_access_error(path: Path) -> str | None:
"""Return a user-facing error if a model directory cannot be scanned."""
try:
if not path.exists():
return f"Model directory does not exist: {path}"
if not path.is_dir():
return f"Model directory is not a directory: {path}"
next(path.iterdir(), None)
except OSError as e:
return (
f"Model directory is not readable: {path} "
f"({type(e).__name__}: {e})"
)
return None
def model_directory_write_error(path: Path, *, create: bool = False) -> str | None:
"""Return a user-facing error if a model directory cannot be written."""
try:
if not path.exists():
if create:
path.mkdir(parents=True, exist_ok=True)
else:
return f"Model directory does not exist: {path}"
if not path.is_dir():
return f"Model directory is not a directory: {path}"
except OSError as e:
return (
f"Model directory is not writable: {path} "
f"({type(e).__name__}: {e})"
)
access_error = model_directory_access_error(path)
if access_error is not None:
return access_error
try:
with tempfile.NamedTemporaryFile(
prefix=".omlx-write-test-",
dir=path,
delete=True,
) as f:
f.write(b"")
f.flush()
except OSError as e:
return (
f"Model directory is not writable: {path} "
f"({type(e).__name__}: {e})"
)
return None
def _iter_readable_entries(path: Path, context: str) -> list[Path]:
"""Return sorted directory entries, or an empty list when scanning fails."""
try:
return sorted(path.iterdir())
except OSError as e:
logger.warning(
"Skipping unreadable %s %s: %s: %s",
context,
path,
type(e).__name__,
e,
)
return []
def _is_readable_dir(path: Path, context: str) -> bool:
try:
return path.is_dir()
except OSError as e:
logger.warning(
"Skipping inaccessible %s %s: %s: %s",
context,
path,
type(e).__name__,
e,
)
return False
def _decode_hf_cache_model_id(path: Path) -> tuple[str, str] | None:
"""Decode models--org--repo into (route_safe_id, repo_id)."""
name = path.name
if not name.startswith("models--"):
return None
encoded = name[len("models--"):]
if not encoded:
return None
parts = encoded.split("--")
if len(parts) == 1:
return parts[0], parts[0]
repo_name = "--".join(parts[1:])
return f"{parts[0]}--{repo_name}", f"{parts[0]}/{repo_name}"
def _resolve_hf_cache_entry(path: Path) -> HfCacheEntry | None:
"""Resolve an HF Hub cache entry (models--Org--Name/) to its active snapshot.
Returns an HfCacheEntry or None if not a valid HF model cache entry.
"""
decoded = _decode_hf_cache_model_id(path)
if decoded is None:
return None
model_id, source_repo_id = decoded
snapshots_dir = path / "snapshots"
if not snapshots_dir.is_dir():
return None
for ref_name in ("main", "master"):
try:
commit_hash = (path / "refs" / ref_name).read_text().strip()
except OSError:
continue
snapshot = snapshots_dir / commit_hash
if snapshot.is_dir():
return HfCacheEntry(snapshot, model_id, source_repo_id)
snapshots = [
p
for p in _iter_readable_entries(snapshots_dir, "HF cache snapshots")
if _is_readable_dir(p, "HF cache snapshot")
]
if not snapshots:
return None
if len(snapshots) == 1:
return HfCacheEntry(snapshots[0], model_id, source_repo_id)
snapshot = max(snapshots, key=lambda p: p.stat().st_mtime)
return HfCacheEntry(snapshot, model_id, source_repo_id)
def _safetensors_has_mlx_metadata(path: Path) -> bool:
"""Return True if any model safetensors shard declares MLX format."""
try:
from safetensors import safe_open
except Exception as e:
logger.debug(f"safetensors import failed while checking {path}: {e}")
return False
for shard in sorted(path.glob("model*.safetensors")):
try:
with safe_open(str(shard), framework="numpy") as f:
metadata = f.metadata() or {}
except Exception as e:
logger.debug(f"Could not read safetensors metadata from {shard}: {e}")
continue
if str(metadata.get("format", "")).lower() == "mlx":
return True
return False
_MLX_NAME_RE = re.compile(r"(^|[-_/])mlx($|[-_/])", re.IGNORECASE)
# Speculative-decoding helper checkpoints (e.g. z-lab/Qwen3.6-27B-DFlash) are
# MLX-loadable drafts even though their safetensors are saved in PyTorch ("pt")
# format and the repo name carries no "mlx" token, so the HF-cache MLX
# heuristic must recognise them explicitly or they vanish from the draft-model
# picker (#1643). Detection delegates to is_helper_model_config so the drafter
# markers stay in sync with helper flagging and engine_pool's drafter
# resolution.
def _is_helper_checkpoint(model_path: Path) -> bool:
"""True if ``model_path`` is a speculative-decoding helper checkpoint.
Must never raise on an unreadable entry: unlike the config reads inside
_register_model, this runs outside discover_models' per-model guard, so
an escaped exception would abort the whole scan. UnicodeDecodeError from
a non-UTF-8 config.json is a ValueError, not a JSONDecodeError.
"""
config_path = model_path / "config.json"
if not config_path.exists():
return False
try:
with open(config_path) as f:
config = json.load(f)
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
return False
return is_helper_model_config(config)
def _is_hf_cache_mlx_compatible(model_dir: Path, source_repo_id: str) -> bool:
"""Heuristic for HF cache entries that can be loaded without conversion."""
if not _is_model_dir(model_dir):
return False
if not list(model_dir.glob("model*.safetensors")):
logger.debug(f"Skipping HF cache model without model*.safetensors: {source_repo_id}")
return False
if _safetensors_has_mlx_metadata(model_dir):
return True
repo_lower = source_repo_id.lower()
if repo_lower.startswith("mlx-community/") or _MLX_NAME_RE.search(source_repo_id):
logger.info(
f"Treating HF cache model as MLX-compatible by repo name: {source_repo_id}"
)
return True
if _is_helper_checkpoint(model_dir):
logger.info(
f"Treating HF cache model as MLX-compatible speculative-decoding "
f"helper: {source_repo_id}"
)
return True
logger.debug(f"Skipping non-MLX HF cache model: {source_repo_id}")
return False
def _register_model(
models: dict[str, DiscoveredModel],
model_dir: Path,
model_id: str,
*,
source_type: str = "local",
source_repo_id: str | None = None,
) -> None:
"""Try to register a single model directory into the models dict."""
if model_id in models:
logger.warning(
f"Duplicate model_id '{model_id}' found in {model_dir}, "
f"keeping version from {models[model_id].model_path}"
)
return
try:
if _is_unsupported_model(model_dir):
logger.info(f"Skipping unsupported model: {model_id}")
return
model_type = detect_model_type(model_dir)
if model_type == "embedding":
engine_type: EngineType = "embedding"
elif model_type == "reranker":
engine_type = "reranker"
elif model_type == "vlm":
engine_type = "vlm"
elif model_type == "audio_stt":
engine_type = "audio_stt"
elif model_type == "audio_tts":
engine_type = "audio_tts"
elif model_type == "audio_sts":
engine_type = "audio_sts"
else:
engine_type = "batched"
estimated_size = estimate_model_size(model_dir)
# Read raw config model_type for sub-type detection (e.g., OCR models)
# and flag speculative-decoding drafters (dFlash/Assistant/MTP).
config_model_type = ""
is_helper = False
try:
import json
with open(model_dir / "config.json") as f:
_config = json.load(f)
config_model_type = _config.get("model_type", "")
is_helper = is_helper_model_config(_config)
except Exception:
pass
thinking_default = detect_thinking_default(model_dir)
preserve_thinking_default = detect_preserve_thinking(model_dir)
model_context_length = _read_model_context_length(model_dir)
models[model_id] = DiscoveredModel(
model_id=model_id,
model_path=str(model_dir),
model_type=model_type,
engine_type=engine_type,
estimated_size=estimated_size,
config_model_type=config_model_type,
thinking_default=thinking_default,
preserve_thinking_default=preserve_thinking_default,
model_context_length=model_context_length,
source_type=source_type,
source_repo_id=source_repo_id,
is_helper=is_helper,
)
size_gb = estimated_size / (1024**3)
logger.info(
f"Discovered model: {model_id} "
f"(type: {model_type}, engine: {engine_type}, size: {size_gb:.2f}GB)"
)
except Exception as e:
logger.error(f"Failed to discover model {model_id}: {e}")
def discover_models(model_dir: Path) -> dict[str, DiscoveredModel]:
"""
Scan model directory with two-level discovery.
Supports both flat and organized directory layouts:
Flat (one level):
model_dir/
├── llama-3b/ → model_id: "llama-3b"
│ ├── config.json
│ └── *.safetensors
└── qwen-7b/ → model_id: "qwen-7b"
Organized (two levels):
model_dir/
├── mlx-community/
│ ├── llama-3b/ → model_id: "llama-3b"
│ └── qwen-7b/ → model_id: "qwen-7b"
└── Qwen/
└── Qwen3-8B/ → model_id: "Qwen3-8B"
If a first-level subdirectory has config.json, it's treated as a model.
Otherwise, its children are scanned for models (organization folder).
Args:
model_dir: Path to directory containing model subdirectories
Returns:
Dictionary mapping model_id to DiscoveredModel
"""
access_error = model_directory_access_error(model_dir)
if access_error is not None:
if "not readable" in access_error:
logger.warning("Skipping directory %s: %s", model_dir, access_error)
return {}
raise ValueError(access_error)
models: dict[str, DiscoveredModel] = {}
for subdir in _iter_readable_entries(model_dir, "model directory"):
if not _is_readable_dir(subdir, "model entry") or subdir.name.startswith("."):
continue
if _is_adapter_dir(subdir):
logger.info(
f"Skipping LoRA adapter: {subdir.name} "
"(oMLX does not support LoRA/PEFT adapters)"
)
elif _is_model_dir(subdir):
# Level 1: direct model folder
_register_model(models, subdir, subdir.name)
else:
# HF Hub cache entry: models--Org--Name/snapshots/<hash>/
hf_resolved = _resolve_hf_cache_entry(subdir)
if hf_resolved is not None:
if _is_hf_cache_mlx_compatible(
hf_resolved.snapshot_path,
hf_resolved.source_repo_id,
):
_register_model(
models,
hf_resolved.snapshot_path,
hf_resolved.model_id,
source_type="hf_cache",
source_repo_id=hf_resolved.source_repo_id,
)
continue
# Level 2: organization folder — scan children
has_children = False
for child in _iter_readable_entries(subdir, "model group"):
if (
not _is_readable_dir(child, "model group entry")
or child.name.startswith(".")
):
continue
if _is_adapter_dir(child):
logger.info(
f"Skipping LoRA adapter: {child.name} "
"(oMLX does not support LoRA/PEFT adapters)"
)
elif _is_model_dir(child):
has_children = True
_register_model(models, child, child.name)
if not has_children:
logger.debug(
f"Skipping {subdir.name}: no config.json found "
f"(not a model or organization folder)"
)
# Fallback: if no models found and the directory itself is a model, register it.
# This supports pointing directly at a single model folder, e.g.:
# /Models/Qwen3.5-9B-MLX-4bit/ (contains config.json and weight files)
if not models and _is_model_dir(model_dir):
_register_model(models, model_dir, model_dir.name)
return models
def discover_models_from_dirs(
model_dirs: list[Path],
) -> dict[str, DiscoveredModel]:
"""
Scan multiple model directories and merge results.
Each directory is scanned using discover_models(). On model_id conflicts,
the first directory's model takes priority (earlier directory wins).
Args:
model_dirs: List of paths to directories containing model subdirectories
Returns:
Dictionary mapping model_id to DiscoveredModel
"""
merged: dict[str, DiscoveredModel] = {}
for model_dir in model_dirs:
access_error = model_directory_access_error(model_dir)
if access_error is not None:
logger.warning(f"Skipping directory {model_dir}: {access_error}")
continue
try:
discovered = discover_models(model_dir)
except ValueError as e:
logger.warning(f"Skipping directory {model_dir}: {e}")
continue
for model_id, info in discovered.items():
if model_id in merged:
logger.warning(
f"Duplicate model_id '{model_id}' found in {model_dir}, "
f"keeping version from {merged[model_id].model_path}"
)
continue
merged[model_id] = info
return merged
def format_size(size_bytes: int) -> str:
"""Format byte size as human-readable string."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if abs(size_bytes) < 1024.0:
return f"{size_bytes:.2f}{unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f}PB"