mirror of
https://github.com/cloudstack-llc/mlx-knife.git
synced 2026-08-27 02:01:29 -04:00
5751545b8b
- Server: exit codes, /v1/models crash fix, vision routing, MLXK2_MAX_TOKENS - Vision: true SSE streaming, hallucination fix (local numbering) - Workspace: list prefix-match, push ambiguous pattern handling - Docs: SERVER-HANDBOOK accuracy updates See CHANGELOG.md for details.
147 lines
5.2 KiB
Python
147 lines
5.2 KiB
Python
"""List models operation for MLX-Knife 2.0."""
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, Tuple
|
|
|
|
from ..core.cache import get_current_model_cache, cache_dir_to_hf
|
|
from .common import build_model_object
|
|
from .workspace import find_matching_workspaces, is_explicit_path
|
|
|
|
|
|
def _compute_display_name(workspace_path: Path, pattern: str) -> str:
|
|
"""Compute display name for workspace based on input pattern.
|
|
|
|
If pattern is relative (./..., ../...), return relative path.
|
|
If pattern is absolute (/...), return absolute path.
|
|
|
|
Args:
|
|
workspace_path: Resolved absolute path to workspace
|
|
pattern: Original input pattern
|
|
|
|
Returns:
|
|
Display name matching input pattern style
|
|
"""
|
|
if pattern.startswith('/'):
|
|
# Absolute pattern → absolute output
|
|
return str(workspace_path)
|
|
|
|
# Relative pattern → relative output
|
|
try:
|
|
pattern_path = Path(pattern).expanduser()
|
|
pattern_resolved = pattern_path.resolve()
|
|
|
|
# Case 1: Exact workspace match (pattern points to this workspace)
|
|
# Display name is the workspace directory name
|
|
if pattern_resolved == workspace_path:
|
|
return workspace_path.name
|
|
|
|
# Case 2: Directory scan (pattern is parent directory containing workspace)
|
|
# Display name is relative to that directory
|
|
if pattern_path.exists() and pattern_path.is_dir():
|
|
return str(workspace_path.relative_to(pattern_resolved))
|
|
|
|
# Case 3: Prefix match (pattern is partial name)
|
|
# Display name is relative to parent directory
|
|
search_dir = pattern_path.parent.resolve()
|
|
return str(workspace_path.relative_to(search_dir))
|
|
except ValueError:
|
|
# Can't compute relative path, fall back to absolute
|
|
return str(workspace_path)
|
|
|
|
|
|
def _latest_snapshot(model_path) -> Tuple[Optional[str], Optional[object]]:
|
|
"""Return (hash, path) for the latest snapshot if any, else (None, None)."""
|
|
snapshots_dir = model_path / "snapshots"
|
|
if not snapshots_dir.exists():
|
|
return None, None
|
|
snapshots = [d for d in snapshots_dir.iterdir() if d.is_dir() and len(d.name) == 40]
|
|
if not snapshots:
|
|
return None, None
|
|
latest = max(snapshots, key=lambda x: x.stat().st_mtime)
|
|
return latest.name, latest
|
|
|
|
|
|
def list_models(pattern: str = None) -> Dict[str, Any]:
|
|
"""List all models in cache with JSON output.
|
|
|
|
Args:
|
|
pattern: Optional pattern to filter models (case-insensitive substring match),
|
|
or a workspace path pattern to list local models.
|
|
|
|
Workspace patterns (start with ./, ../, or /):
|
|
- Exact: "./my-model" → list single workspace
|
|
- Prefix: "./gemma-" → list all workspaces starting with "gemma-"
|
|
"""
|
|
# Check for workspace path patterns first (ADR-018 Phase 0c compatibility)
|
|
# Explicit paths (./foo, ../foo, /foo) are always treated as workspace patterns
|
|
if pattern and is_explicit_path(pattern):
|
|
workspace_matches = find_matching_workspaces(pattern)
|
|
models = []
|
|
for workspace_path in workspace_matches:
|
|
model_obj = build_model_object(
|
|
str(workspace_path), # hf_name = absolute path for workspaces
|
|
workspace_path, # model_root
|
|
workspace_path # selected_path (no snapshots in workspace)
|
|
)
|
|
# Add display_name for human output (respects input pattern style)
|
|
model_obj["display_name"] = _compute_display_name(workspace_path, pattern)
|
|
models.append(model_obj)
|
|
# Return workspace results (may be empty if no matches)
|
|
# Do NOT fall through to cache search for explicit paths
|
|
return {
|
|
"status": "success",
|
|
"command": "list",
|
|
"data": {
|
|
"models": models,
|
|
"count": len(models)
|
|
},
|
|
"error": None
|
|
}
|
|
|
|
models = []
|
|
model_cache = get_current_model_cache()
|
|
|
|
if not model_cache.exists():
|
|
return {
|
|
"status": "success",
|
|
"command": "list",
|
|
"data": {
|
|
"models": models,
|
|
"count": 0
|
|
},
|
|
"error": None
|
|
}
|
|
|
|
# Find all model directories
|
|
for model_dir in model_cache.iterdir():
|
|
if not model_dir.is_dir() or not model_dir.name.startswith("models--"):
|
|
continue
|
|
|
|
hf_name = cache_dir_to_hf(model_dir.name)
|
|
# Hide test sentinel directories from listings
|
|
if "TEST-CACHE-SENTINEL" in hf_name:
|
|
continue
|
|
|
|
# Apply pattern filter if specified
|
|
if pattern and pattern.strip():
|
|
if pattern.lower() not in hf_name.lower():
|
|
continue
|
|
|
|
# Select snapshot (prefer latest) and build model object
|
|
_hash, snap_path = _latest_snapshot(model_dir)
|
|
model_obj = build_model_object(hf_name, model_dir, snap_path if snap_path is not None else model_dir)
|
|
models.append(model_obj)
|
|
|
|
# Sort by name for consistent output
|
|
models.sort(key=lambda x: x["name"])
|
|
|
|
return {
|
|
"status": "success",
|
|
"command": "list",
|
|
"data": {
|
|
"models": models,
|
|
"count": len(models)
|
|
},
|
|
"error": None
|
|
}
|