mirror of
https://github.com/cloudstack-llc/mlx-knife.git
synced 2026-07-19 14:43:36 -04:00
2.0.0-alpha.1: human output default; strict health (#27, PyTorch index)
See CHANGELOG.md and README.md
This commit is contained in:
+30
-92
@@ -1,22 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [2.0.0-alpha] - 2025-08-29
|
## 1.1.1 — Pending
|
||||||
|
|
||||||
### Fixed
|
- Fix (Issue #27): Strict health completeness for multi-shard models in 1.x:
|
||||||
- Python 3.9 LibreSSL Warning parity with 1.1.0 (Issue #22):
|
- Recognize both safetensors (`model.safetensors.index.json`) and PyTorch (`pytorch_model.bin.index.json`) JSON indices.
|
||||||
- Suppress `urllib3 v2 only supports OpenSSL 1.1.1+` warning on macOS system Python 3.9
|
- Validate only the present format’s shards (exist, non-empty, not LFS pointers) to avoid false negatives.
|
||||||
- Implemented globally in `mlxk2/__init__.py` with additional just‑in‑time safeguard in `mlxk2/operations/pull.py`
|
- Aligns 1.x health behavior with 2.0.0-alpha.1 policy.
|
||||||
- Effect: Clean CLI output across all 2.0 commands; tests remain green
|
|
||||||
|
|
||||||
- Health check completeness for multi‑shard safetensors (Issue #27 parity):
|
## 2.0.0-alpha.1 — 2025-08-31
|
||||||
- If `model.safetensors.index.json` exists, verify all referenced shards exist, are non‑empty and not LFS pointers
|
|
||||||
- Without index, detect `model-XXXXX-of-YYYYY.safetensors` and require full set; subsets/empty shards → unhealthy
|
|
||||||
- Improved LFS detection (recursive)
|
|
||||||
- (Summary kept concise here; details are tracked in GitHub Issues)
|
|
||||||
|
|
||||||
### Tests
|
- New JSON-first CLI (`mlxk2`, `mlxk-json`); `--json` for machine-readable output (new vs 1.0.0).
|
||||||
- 2.0 suite: 45/45 passed (default `tests_2.0/`) on Python 3.9 and 3.10
|
- Human output by default: improved formatting, new Type column, relative Modified; MLX-only compact view with `--all`, `--health`, `--verbose` flags.
|
||||||
- Additional deterministic tests for Issue #27: `tests_2.0/test_health_multifile.py` (5 cases) → all passing
|
- Stricter health checks for sharded models (Issue #27); robust model resolution (fuzzy, `@hash`); `rm` cleans whole model and locks.
|
||||||
|
- Packaging/tooling: dynamic versioning; multi-Python test script; Python 3.9–3.13; timezone-aware datetimes.
|
||||||
|
- **Not included yet: server and run** (use 1.x).
|
||||||
|
|
||||||
## [1.1.0] - 2025-08-26 - **STABLE RELEASE** 🚀
|
## [1.1.0] - 2025-08-26 - **STABLE RELEASE** 🚀
|
||||||
|
|
||||||
@@ -95,90 +92,31 @@
|
|||||||
- **Root Cause**: `generate_batch()` lacked End-Token filtering present in `generate_streaming()`
|
- **Root Cause**: `generate_batch()` lacked End-Token filtering present in `generate_streaming()`
|
||||||
- **Fix**: Ported filtering logic with new `_filter_end_tokens_from_response()` method
|
- **Fix**: Ported filtering logic with new `_filter_end_tokens_from_response()` method
|
||||||
- **Affected**: `mlxk run model "prompt" --no-stream` and Server API `"stream": false`
|
- **Affected**: `mlxk run model "prompt" --no-stream` and Server API `"stream": false`
|
||||||
- **Impact**: Professional clean output - no visible `</s>`, `<|im_end|>`, `<|end|>` tokens
|
- **Impact**: No more end tokens appearing in the final output in non-streaming mode
|
||||||
- **Test Coverage**: 47/48 comprehensive tests validate fix across all model architectures
|
|
||||||
|
|
||||||
### Test Infrastructure Improvements 🧪
|
### Enhanced
|
||||||
- **New Test Suite**: `tests/integration/test_end_token_issue.py` with 48 systematic tests
|
- Better default for `--max-tokens`: `None` → model-aware limits
|
||||||
- **RAM-Aware Testing**: Automatic model selection based on available system memory
|
- Improved consistency between streaming and non-streaming generation
|
||||||
- **Flaky Test Fix**: Improved server lifecycle management with proper port cleanup
|
- Clearer server logs indicating active token policies
|
||||||
- **Blocking Read Fix**: Fixed timeout issues in server startup validation tests
|
|
||||||
- **Test Count**: 132/132 standard tests + 48 server tests (180 total)
|
|
||||||
|
|
||||||
### Documentation Updates 📚
|
### Technical
|
||||||
- **TESTING.md**: New server test procedures, updated test counts (132/132), comprehensive server test guide
|
- 15 new tests across server and CLI to validate token policies
|
||||||
- **Test Categories**: Clear separation of standard tests vs resource-intensive server tests
|
- Internal refactoring for token handling to avoid duplication
|
||||||
- **Server Test Documentation**: RAM requirements, timing expectations, model compatibility
|
|
||||||
|
|
||||||
### Architecture Quality 🏗️
|
|
||||||
- **End-Token Consistency**: Streaming and non-streaming pipelines now identical in behavior
|
|
||||||
- **Clean Code**: Unified filtering logic eliminates code duplication between pipelines
|
|
||||||
- **Regression Prevention**: Comprehensive test coverage prevents future End-Token issues
|
|
||||||
- **Professional Output**: All models and modes produce clean, professional responses
|
|
||||||
- **Test Stability**: Eliminated flaky tests and timeouts for reliable CI/CD
|
|
||||||
|
|
||||||
## [1.1.0-beta1] - 2025-08-21
|
## [1.1.0-beta1] - 2025-08-21
|
||||||
|
|
||||||
### Major Features 🚀
|
### Added
|
||||||
- **Issues #15 & #16**: Dynamic Model-Aware Token Limits
|
- Dynamic model-aware token limits (context-length sensitive)
|
||||||
- Eliminated hardcoded 500/2000 token defaults with intelligent model-based limits
|
- CLI `--max-tokens` default changed to `None` (was 2000)
|
||||||
- **Phi-3-mini**: 4096 context → 2048 server tokens, 4096 interactive (8x improvement)
|
- Server leverages the same dynamic limits
|
||||||
- **Qwen2.5-30B**: 262,144 context → 131,072 server tokens, 262,144 interactive (524x improvement!)
|
|
||||||
- Context-aware policies: Interactive mode uses full context, server mode uses context/2 for DoS protection
|
|
||||||
- Automatic adaptation to new models with larger context windows (future-proof)
|
|
||||||
|
|
||||||
### Enhanced Web Client 🌐
|
### Improved
|
||||||
- **Model Token Capacity Display**: Shows "Ready with Mistral-7B (32,768 tokens)" in header
|
- End-token filtering consistency across streaming and non-streaming modes
|
||||||
- **Enhanced `/v1/models` API**: Now exposes `context_length` field for model capabilities
|
- Robustness in model loading and memory management
|
||||||
- **Button State Management**: Clear Chat properly disabled during streaming with CSS styling
|
|
||||||
- **Streaming Status Tracking**: Added `isStreaming` flag with "Generating response..." feedback
|
|
||||||
|
|
||||||
### Interactive Mode Improvements 💡
|
### Tests
|
||||||
- **Smart CLI Defaults**: `mlxk run <model> "prompt"` automatically uses optimal token limits per model
|
- 114/114 tests passing
|
||||||
- **No Configuration Needed**: Users benefit immediately without changing usage patterns
|
- Server tests behind `@pytest.mark.server` (opt-in)
|
||||||
- **Explicit Control Preserved**: `--max-tokens` arguments still respected and capped at model context
|
|
||||||
- **Clean Type Safety**: Proper `Optional[int]` handling eliminates fragile CLI guessing
|
|
||||||
|
|
||||||
### Technical Architecture 🏗️
|
|
||||||
- **`get_model_context_length()` function**: Extracts context length from model configs with multiple fallback keys
|
|
||||||
- **Enhanced MLXRunner**: `get_effective_max_tokens()` method for context-aware token limiting
|
|
||||||
- **Server API Updates**: All endpoints use model-aware limits with DoS protection
|
|
||||||
- **Unified Token Logic**: Single source of truth through MLXRunner eliminates duplicate code
|
|
||||||
- **Backward Compatible**: All existing CLI arguments and APIs work unchanged
|
|
||||||
|
|
||||||
### Performance Impact 📊
|
|
||||||
- **Modern Models Unleashed**: Large-context models can now use their full capabilities
|
|
||||||
- **Real-World Benefits**: No more artificial 500-token truncation for 100K+ context models
|
|
||||||
- **Smart Server Limits**: Automatic DoS protection while maximizing usable context
|
|
||||||
- **Zero Magic Numbers**: Clean architecture with clear `None` vs explicit value semantics
|
|
||||||
|
|
||||||
### Testing & Quality Assurance ✅
|
|
||||||
- **Comprehensive Coverage**: 131/131 tests passing (expansion from 114 tests)
|
|
||||||
- **20 new unit tests**: Covering CLI None-handling, model context extraction, effective token calculation
|
|
||||||
- **5 server integration tests**: Real-world validation with actual MLX models
|
|
||||||
- **Extreme Model Testing**: Validated with models from 1B to 30B parameters, up to 256K context
|
|
||||||
- **Edge Case Handling**: Unknown models, missing configs, CLI argument combinations
|
|
||||||
|
|
||||||
### Issue #14 Model Compatibility Validation
|
|
||||||
**Chat Self-Conversation Fix tested across model spectrum:**
|
|
||||||
|
|
||||||
| Model | Size | RAM (GB) | Context | Status | Architecture |
|
|
||||||
|-------|------|----------|---------|--------|-------------|
|
|
||||||
| **Llama-3.2-1B-Instruct-4bit** | 1B | 2 | 131,072 | ✅ PASSED | Llama |
|
|
||||||
| **Llama-3.2-3B-Instruct-4bit** | 3B | 4 | 131,072 | ✅ PASSED | Llama |
|
|
||||||
| **Phi-3-mini-4k-instruct-4bit** | 4B | 5 | 4,096 | ✅ PASSED | Phi-3 |
|
|
||||||
| **Mistral-7B-Instruct-v0.2-4bit** | 7B | 8 | 32,768 | ✅ PASSED | Mistral |
|
|
||||||
| **Mixtral-8x7B-Instruct-v0.1-4bit** | 8x7B | 16 | 32,768 | ✅ PASSED | Mixtral MoE |
|
|
||||||
| **Mistral-Small-3.2-24B-Instruct-2506-4bit** | 24B | 20 | 32,768 | ✅ PASSED | Mistral |
|
|
||||||
| **Qwen3-30B-A3B-Instruct-2507-4bit** | 30B | 24 | 262,144 | ✅ PASSED | Qwen |
|
|
||||||
|
|
||||||
**Validation Results**: 7/7 models passed - comprehensive coverage from 1B to 30B parameters across all major MLX architectures ensures robust chat stop token handling.
|
|
||||||
|
|
||||||
### Beta Status Notes ⚠️
|
|
||||||
- **Core Functionality**: Solid foundation with comprehensive test coverage
|
|
||||||
- **Known Limitation**: Server deadlock possible under extreme concurrent model loading stress
|
|
||||||
- **Workaround**: Avoid simultaneous heavy model operations (normal usage unaffected)
|
|
||||||
- **Real-World Ready**: Significant improvements ready for community testing and feedback
|
|
||||||
|
|
||||||
## [1.0.4] - 2025-08-19
|
## [1.0.4] - 2025-08-19
|
||||||
|
|
||||||
@@ -198,7 +136,7 @@
|
|||||||
- 🔄 Smart model switching: Choice to keep or clear chat history when switching models
|
- 🔄 Smart model switching: Choice to keep or clear chat history when switching models
|
||||||
- 🌐 Responsive design: Full viewport height utilization, optimized screen space usage
|
- 🌐 Responsive design: Full viewport height utilization, optimized screen space usage
|
||||||
- 🎯 Clear UX: "Clear Chat" instead of ambiguous "Clear" button
|
- 🎯 Clear UX: "Clear Chat" instead of ambiguous "Clear" button
|
||||||
- 🏴 English dialogs: Custom modal dialogs replace German OS dialogs
|
- 🏴 English dialogs: Custom modal dialogs replace German OS dialogs
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Automated Server Testing Infrastructure**:
|
- **Automated Server Testing Infrastructure**:
|
||||||
|
|||||||
+24
-9
@@ -6,6 +6,21 @@ First off, thank you for considering contributing to MLX Knife! It's people like
|
|||||||
|
|
||||||
We're a small team passionate about making MLX models accessible and easy to use on Apple Silicon. We welcome contributions from everyone who shares this vision.
|
We're a small team passionate about making MLX models accessible and easy to use on Apple Silicon. We welcome contributions from everyone who shares this vision.
|
||||||
|
|
||||||
|
## 2.0 Alpha (JSON CLI) – Contributor Notes
|
||||||
|
|
||||||
|
- Code path: `mlxk2/` (entry points: `mlxk2`, `mlxk-json`).
|
||||||
|
- Default output: human-friendly tables/text; pass `--json` to emit the exact JSON API (spec v0.1.2).
|
||||||
|
- Supported commands: `list`, `health`, `show`, `pull`, `rm` (no server/run in 2.0 yet — use `mlxk` 1.x for those).
|
||||||
|
- Tests: default suite is `tests_2.0/` (see `pytest.ini`); legacy `tests/` on demand.
|
||||||
|
- Human output options:
|
||||||
|
- `list`: `--all` (all frameworks), `--health` (add column), `--verbose` (full org/model names).
|
||||||
|
- Compact default: MLX-only, compact names (strip `mlx-community/`), no Framework column.
|
||||||
|
- Cache safety: tests use isolated temp caches; read-only ops are safe; coordinate `pull`/`rm` when using a shared user cache.
|
||||||
|
- Spec discipline: JSON schema/spec changes require a version bump in `mlxk2/spec.py` (see CLAUDE.md and docs/).
|
||||||
|
|
||||||
|
These 2.0 alpha changes do not affect 1.x (`mlx_knife/`) behavior.
|
||||||
|
|
||||||
|
|
||||||
## How Can I Contribute?
|
## How Can I Contribute?
|
||||||
|
|
||||||
### Reporting Bugs
|
### Reporting Bugs
|
||||||
@@ -31,8 +46,8 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
|
|||||||
|
|
||||||
1. Fork the repository and create your branch from `main`
|
1. Fork the repository and create your branch from `main`
|
||||||
2. If you've added code, add tests that cover your changes
|
2. If you've added code, add tests that cover your changes
|
||||||
3. Ensure the test suite passes locally: `pytest tests/`
|
3. Ensure the test suite passes locally: `pytest tests_2.0/ -v`
|
||||||
4. Make sure your code follows the existing style: `ruff check mlx_knife/ --fix`
|
4. Make sure your code follows the existing style: `ruff check mlxk2/ --fix`
|
||||||
5. Write a clear commit message
|
5. Write a clear commit message
|
||||||
6. Open a Pull Request with a clear title and description
|
6. Open a Pull Request with a clear title and description
|
||||||
|
|
||||||
@@ -43,18 +58,18 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
|
|||||||
git clone https://github.com/mzau/mlx-knife.git
|
git clone https://github.com/mzau/mlx-knife.git
|
||||||
cd mlx-knife
|
cd mlx-knife
|
||||||
|
|
||||||
# Install in development mode with all dependencies
|
# Install in development mode
|
||||||
pip install -e ".[dev,test]"
|
pip install -e .
|
||||||
|
|
||||||
# Download a test model (required for full test suite)
|
# Download a test model (required for full test suite)
|
||||||
mlxk pull mlx-community/Phi-3-mini-4k-instruct-4bit
|
mlxk pull mlx-community/Phi-3-mini-4k-instruct-4bit
|
||||||
|
|
||||||
# Run tests
|
# Run tests (2.0 default)
|
||||||
pytest
|
pytest tests_2.0/ -v
|
||||||
|
|
||||||
# Check code style
|
# Check code style (2.0)
|
||||||
ruff check mlx_knife/
|
ruff check mlxk2/
|
||||||
mypy mlx_knife/
|
mypy mlxk2/
|
||||||
|
|
||||||
# Test with a real model
|
# Test with a real model
|
||||||
mlxk run Phi-3-mini "Hello world"
|
mlxk run Phi-3-mini "Hello world"
|
||||||
|
|||||||
@@ -1,39 +1,91 @@
|
|||||||
# <img src="https://github.com/mzau/mlx-knife/raw/main/broke-logo.png" alt="BROKE Logo" width="60" style="vertical-align: middle;"> MLX-Knife 2.0.0-alpha
|
# <img src="https://github.com/mzau/mlx-knife/raw/main/broke-logo.png" alt="BROKE Logo" width="60" style="vertical-align: middle;"> MLX-Knife 2.0.0-alpha.1
|
||||||
|
|
||||||
**JSON-First Model Management for Automation & Scripting**
|
<p align="center">
|
||||||
|
<img src="https://github.com/mzau/mlx-knife/raw/main/mlxk-demo.gif" alt="MLX Knife Demo" width="1000">
|
||||||
|
</p>
|
||||||
|
|
||||||
> **🚧 Alpha Development Branch:** This is the `feature/2.0.0-json-only` branch containing MLX-Knife 2.0.0-alpha. For stable production use, see [MLX-Knife 1.1.0](https://github.com/mzau/mlx-knife/tree/main).
|
## New: JSON-First Model Management for Automation & Scripting
|
||||||
|
|
||||||
[](https://github.com/mzau/mlx-knife/releases)
|
> **🚧 Alpha Development:** Server and run are not included yet in 2.0.0-alpha.1. Use [MLX-Knife 1.1.0](https://github.com/mzau/mlx-knife/tree/main) for those features.
|
||||||
|
|
||||||
|
**Stable Version: 1.1.0**
|
||||||
|
|
||||||
|
[](https://github.com/mzau/mlx-knife/releases)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
[](https://support.apple.com/en-us/HT211814)
|
||||||
|
[](https://github.com/ml-explore/mlx)
|
||||||
|
[](https://github.com/sponsors/mzau)
|
||||||
|
|
||||||
[](#testing)
|
[](#testing)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
- **List & Manage Models**: Browse your HuggingFace cache with MLX-specific filtering
|
||||||
|
- **Model Information**: Detailed model metadata including quantization info
|
||||||
|
- **Download Models**: Pull models from HuggingFace with progress tracking
|
||||||
|
- **Run Models**: Native MLX execution with streaming and chat modes (version 1.0.0 stable only)
|
||||||
|
- **Health Checks**: Verify model integrity and completeness
|
||||||
|
- **Cache Management**: Clean up and organize your model storage
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- macOS with Apple Silicon (M1/M2/M3)
|
||||||
|
- Python 3.9+ (native macOS version or newer)
|
||||||
|
- 8GB+ RAM recommended + RAM to run LLM
|
||||||
|
|
||||||
|
### Python Compatibility
|
||||||
|
MLX Knife has been comprehensively tested and verified on:
|
||||||
|
|
||||||
|
✅ **Python 3.9.6** (native macOS) - Primary target
|
||||||
|
✅ **Python 3.10-3.13** - Fully compatible
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Installation (local development)
|
# Installation (local development)
|
||||||
git clone https://github.com/mzau/mlx-knife.git -b feature/2.0.0-json-only
|
git clone https://github.com/mzau/mlx-knife.git
|
||||||
cd mlx-knife
|
cd mlx-knife
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
```
|
||||||
# Basic usage - JSON API
|
# Install with development tools (ruff, mypy, tests)
|
||||||
mlxk-json list --json | jq '.data.models[].name'
|
pip install -e ".[dev,test]"
|
||||||
mlxk-json health --json | jq '.data.summary'
|
|
||||||
mlxk-json show "Phi-3-mini" --json | jq '.data.model_info'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**What's New:** JSON-first architecture for automation and scripting
|
## Human output (default)
|
||||||
**What's Missing:** Server mode, run command (use MLX-Knife 1.x for those)
|
mlxk2 list
|
||||||
|
mlxk2 list --health
|
||||||
|
mlxk2 list --all --verbose
|
||||||
|
mlxk2 health
|
||||||
|
mlxk2 show "mlx-community/Phi-3-mini-4k-instruct-4bit"
|
||||||
|
|
||||||
|
## JSON API
|
||||||
|
mlxk2 list --json | jq '.data.models[].name'
|
||||||
|
mlxk2 health --json | jq '.data.summary'
|
||||||
|
mlxk2 show "Phi-3-mini" --json | jq '.data.model'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Differences vs 1.0.0
|
||||||
|
|
||||||
|
- CLI: new entry points `mlxk2` and `mlxk-json` (1.0.0 used `mlxk`).
|
||||||
|
- Output: human output by default; add `--json` for machine-readable responses (new vs 1.0.0).
|
||||||
|
- List formatting: improved compact table with relative times in the Modified column (e.g., 3h ago) and a new Type column; compact MLX-only view by default.
|
||||||
|
- Flags (human-only): `--all` (all frameworks), `--health` (add Health column), `--verbose` (show full `org/model`).
|
||||||
|
- JSON API: unchanged schema vs spec v0.1.2; CLI accepts `--json` after subcommands.
|
||||||
|
- Missing features (compared to 1.0.0): server and run are not included in 2.0 alpha.1 (use `mlxk` 1.x).
|
||||||
|
|
||||||
## ⚠️ Alpha Status Disclaimer
|
## ⚠️ Alpha Status Disclaimer
|
||||||
|
|
||||||
MLX-Knife 2.0.0-alpha is **feature-complete for JSON operations** with production-quality reliability:
|
This is an alpha because:
|
||||||
|
- Not feature-complete vs 1.0.0 (server and run pending).
|
||||||
|
- Major internal refactor to a JSON-first CLI (new package `mlxk2`).
|
||||||
|
|
||||||
- ✅ **Core functionality works:** All 5 commands (`list`, `health`, `show`, `pull`, `rm`)
|
Status:
|
||||||
- ✅ **Test status:** 45/45 passing with comprehensive edge case coverage
|
- ✅ Core commands: `list`, `health`, `show`, `pull`, `rm`.
|
||||||
- ✅ **Production use:** Suitable for broke-cluster integration and automation
|
- ✅ JSON outputs stable and schema-aligned; human output available by default.
|
||||||
- ✅ **Parallel use:** Deploy alongside MLX-Knife 1.x for server functionality
|
- ✅ Suitable for automation/integration; can run alongside 1.x for server/run.
|
||||||
|
|
||||||
## What 2.0.0-alpha Includes
|
## What 2.0.0-alpha Includes
|
||||||
|
|
||||||
@@ -51,7 +103,7 @@ MLX-Knife 2.0.0-alpha is **feature-complete for JSON operations** with productio
|
|||||||
|---------|----------------|---------|
|
|---------|----------------|---------|
|
||||||
| 🔄 `server` | 2.0.0-rc | OpenAI-compatible API server |
|
| 🔄 `server` | 2.0.0-rc | OpenAI-compatible API server |
|
||||||
| 🔄 `run` | 2.0.0-rc | Interactive model execution |
|
| 🔄 `run` | 2.0.0-rc | Interactive model execution |
|
||||||
| 🔄 Human-readable output | 2.0.0-rc | CLI formatting layer |
|
| ✅ Human-readable output | 2.0.0-alpha.1 | CLI formatting layer |
|
||||||
| 🔄 `embed` | TBD | Embedding generation (if merged from 1.x) |
|
| 🔄 `embed` | TBD | Embedding generation (if merged from 1.x) |
|
||||||
|
|
||||||
## Installation & Parallel Usage
|
## Installation & Parallel Usage
|
||||||
@@ -63,8 +115,8 @@ MLX-Knife 2.0.0-alpha is **feature-complete for JSON operations** with productio
|
|||||||
pip install -e /path/to/mlx-knife
|
pip install -e /path/to/mlx-knife
|
||||||
|
|
||||||
# Verify installation
|
# Verify installation
|
||||||
mlxk-json --version # → MLX-Knife JSON 2.0.0-alpha
|
mlxk-json --version # → mlxk2 2.0.0-alpha.1
|
||||||
mlxk2 --version # → MLX-Knife JSON 2.0.0-alpha
|
mlxk2 --version # → mlxk2 2.0.0-alpha.1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Parallel with MLX-Knife 1.x
|
### Parallel with MLX-Knife 1.x
|
||||||
@@ -90,7 +142,7 @@ python -m mlxk2.cli list # 2.0 - Module invocation
|
|||||||
|
|
||||||
## JSON API Documentation
|
## JSON API Documentation
|
||||||
|
|
||||||
> **📋 Complete API Specification**: See [docs/json-api-specification.md](docs/json-api-specification.md) for comprehensive JSON schema, error codes, and integration examples.
|
> **📋 Complete API Specification**: See the JSON API spec on GitHub for comprehensive schema, error codes, and examples: [JSON API Specification](https://github.com/mzau/mlx-knife/blob/feature/2.0.0-alpha.1/docs/json-api-specification.md)
|
||||||
|
|
||||||
### Command Structure
|
### Command Structure
|
||||||
|
|
||||||
@@ -107,24 +159,32 @@ All commands follow this JSON response format:
|
|||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
|
For full, up-to-date examples for every command, refer to the spec on GitHub: [JSON API Specification](https://github.com/mzau/mlx-knife/blob/feature/2.0.0-alpha.1/docs/json-api-specification.md)
|
||||||
|
|
||||||
#### List Models
|
#### List Models
|
||||||
```bash
|
```bash
|
||||||
mlxk-json list --json
|
mlxk-json list --json
|
||||||
# Output:
|
# Output:
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"command": "list",
|
"command": "list",
|
||||||
"data": {
|
"data": {
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
"name": "mlx-community/Phi-3-mini-4k-instruct-4bit",
|
"name": "mlx-community/Phi-3-mini-4k-instruct-4bit",
|
||||||
"hashes": ["e9675aa3def456789abcdef0123456789abcdef0"],
|
"hash": "a5339a41b2e3abcdefgh1234567890ab12345678",
|
||||||
"cached": true
|
"size_bytes": 4613734656,
|
||||||
}
|
"last_modified": "2024-10-15T08:23:41Z",
|
||||||
],
|
"framework": "MLX",
|
||||||
"count": 1
|
"model_type": "chat",
|
||||||
},
|
"capabilities": ["text-generation", "chat"],
|
||||||
"error": null
|
"health": "healthy",
|
||||||
|
"cached": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"error": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -133,21 +193,46 @@ mlxk-json list --json
|
|||||||
mlxk-json health --json
|
mlxk-json health --json
|
||||||
# Output:
|
# Output:
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"command": "health",
|
"command": "health",
|
||||||
"data": {
|
"data": {
|
||||||
"healthy": [...],
|
"healthy": [
|
||||||
"unhealthy": [...],
|
{ "name": "mlx-community/Phi-3-mini-4k-instruct-4bit", "status": "healthy", "reason": "Model is healthy" }
|
||||||
"summary": {"total": 5, "healthy_count": 4, "unhealthy_count": 1}
|
],
|
||||||
},
|
"unhealthy": [],
|
||||||
"error": null
|
"summary": { "total": 1, "healthy_count": 1, "unhealthy_count": 0 }
|
||||||
|
},
|
||||||
|
"error": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Show Model Details
|
#### Show Model Details
|
||||||
```bash
|
```bash
|
||||||
mlxk-json show "Phi-3-mini" --json --files
|
mlxk-json show "Phi-3-mini" --json --files
|
||||||
# Output includes file listings, model config, capabilities
|
# Output (simplified):
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"command": "show",
|
||||||
|
"data": {
|
||||||
|
"model": {
|
||||||
|
"name": "mlx-community/Phi-3-mini-4k-instruct-4bit",
|
||||||
|
"hash": "a5339a41b2e3abcdefgh1234567890ab12345678",
|
||||||
|
"size_bytes": 4613734656,
|
||||||
|
"framework": "MLX",
|
||||||
|
"model_type": "chat",
|
||||||
|
"capabilities": ["text-generation", "chat"],
|
||||||
|
"last_modified": "2024-10-15T08:23:41Z",
|
||||||
|
"health": "healthy",
|
||||||
|
"cached": true
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{"name": "config.json", "size": "1.2KB", "type": "config"},
|
||||||
|
{"name": "model.safetensors", "size": "2.3GB", "type": "weights"}
|
||||||
|
],
|
||||||
|
"metadata": null
|
||||||
|
},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Hash Syntax Support
|
### Hash Syntax Support
|
||||||
@@ -185,7 +270,7 @@ mlxk-json health --json | jq '.data.summary'
|
|||||||
|
|
||||||
## Real-World Examples
|
## Real-World Examples
|
||||||
|
|
||||||
> **🔗 Integration Reference**: External projects should implement against [docs/json-api-specification.md](docs/json-api-specification.md) - this alpha phase helps validate that specification matches actual implementation.
|
> **🔗 Integration Reference**: External projects should implement against the JSON API spec on GitHub — this alpha phase validates that implementation matches documentation: [JSON API Specification](https://github.com/mzau/mlx-knife/blob/feature/2.0.0-alpha.1/docs/json-api-specification.md)
|
||||||
|
|
||||||
### Broke-Cluster Integration
|
### Broke-Cluster Integration
|
||||||
```bash
|
```bash
|
||||||
@@ -243,7 +328,7 @@ pytest tests/ -v
|
|||||||
# - Model naming logic
|
# - Model naming logic
|
||||||
# - Robustness testing
|
# - Robustness testing
|
||||||
|
|
||||||
# Current status: 45/45 passing ✅
|
# Current status: all current 2.0 tests pass (some optional schema tests may be skipped without extras)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Revolutionary Test Architecture:**
|
**Revolutionary Test Architecture:**
|
||||||
@@ -258,9 +343,8 @@ pytest tests/ -v
|
|||||||
- **Health Check False Positive**: Health check may report incomplete downloads as healthy during model pull operations (affects both 1.1.0 and 2.0.0-alpha)
|
- **Health Check False Positive**: Health check may report incomplete downloads as healthy during model pull operations (affects both 1.1.0 and 2.0.0-alpha)
|
||||||
|
|
||||||
### Alpha Limitations
|
### Alpha Limitations
|
||||||
- No interactive prompts (use `--force` flag for rm operations)
|
- Server and run not included (use 1.x)
|
||||||
- JSON output only (no human-readable formatting)
|
- Limited error message UX in some paths (to be refined)
|
||||||
- Limited error message user experience (coming in beta)
|
|
||||||
|
|
||||||
### GitHub Issues
|
### GitHub Issues
|
||||||
- **Issue #18**: Server signal handling limitation (known, will fix in 2.0.0-rc)
|
- **Issue #18**: Server signal handling limitation (known, will fix in 2.0.0-rc)
|
||||||
@@ -301,7 +385,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
|||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/mzau/mlx-knife/issues)
|
- **Issues**: [GitHub Issues](https://github.com/mzau/mlx-knife/issues)
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/mzau/mlx-knife/discussions)
|
- **Discussions**: [GitHub Discussions](https://github.com/mzau/mlx-knife/discussions)
|
||||||
- **API Specification**: [docs/json-api-specification.md](docs/json-api-specification.md) - Complete JSON schema
|
- **API Specification**: [JSON API Specification](https://github.com/mzau/mlx-knife/blob/feature/2.0.0-alpha.1/docs/json-api-specification.md)
|
||||||
- **Documentation**: See `docs/` directory for technical details
|
- **Documentation**: See `docs/` directory for technical details
|
||||||
|
|
||||||
**For production use**: Consider MLX-Knife 1.1.0 until 2.0.0-beta is available.
|
**For production use**: Consider MLX-Knife 1.1.0 until 2.0.0-beta is available.
|
||||||
@@ -316,33 +400,29 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
|||||||
|
|
||||||
*MLX-Knife 2.0.0-alpha - Built for automation, tested for reliability, designed for the future.*
|
*MLX-Knife 2.0.0-alpha - Built for automation, tested for reliability, designed for the future.*
|
||||||
|
|
||||||
## Local Safety Setup (Optional)
|
## Sponsors
|
||||||
|
|
||||||
To keep local coordination files out of Git and avoid accidental pushes during development:
|
<div align="left" style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center;">
|
||||||
|
<a href="https://github.com/tileslauncher" title="Tiles Launcher">
|
||||||
|
<img src="https://github.com/tileslauncher.png" alt="Tiles Launcher" width="48" style="width:48px; height:auto; max-width:100%;">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
- Ignore locally (branch-independent): add to `.git/info/exclude`
|
Support this project: [GitHub Sponsors → mlx-knife](https://github.com/sponsors/mzau)
|
||||||
- `AGENTS.md`
|
|
||||||
- `CLAUDE.md`
|
|
||||||
- Local hooks (not versioned):
|
|
||||||
- `.git/hooks/pre-commit` blocks commits including `AGENTS.md`/`CLAUDE.md`.
|
|
||||||
- `.git/hooks/pre-push` blocks pushes of the current branch. Override once with `ALLOW_PUSH=1 git push`.
|
|
||||||
|
|
||||||
Minimal pre-commit example:
|
Special thanks to early supporters and users providing feedback during the 2.0 alpha.
|
||||||
```bash
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
staged=$(git diff --cached --name-only || true)
|
|
||||||
for f in AGENTS.md CLAUDE.md; do
|
|
||||||
echo "$staged" | grep -qx "$f" && { echo "Commit blocked: $f" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
Minimal pre-push example:
|
|
||||||
```bash
|
## Acknowledgments
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
- Built for Apple Silicon using the [MLX framework](https://github.com/ml-explore/mlx)
|
||||||
[ "${ALLOW_PUSH:-}" = "1" ] && exit 0
|
- Models hosted by the [MLX Community](https://huggingface.co/mlx-community) on HuggingFace
|
||||||
br=$(git rev-parse --abbrev-ref HEAD)
|
- Inspired by [ollama](https://ollama.ai)'s user experience
|
||||||
while read -r l _ r _; do [ "$l" = "refs/heads/$br" ] && { echo "Push blocked: $br" >&2; exit 1; }; done
|
|
||||||
exit 0
|
---
|
||||||
```
|
|
||||||
|
<p align="center">
|
||||||
|
<b>Made with ❤️ by The BROKE team <img src="broke-logo.png" alt="BROKE Logo" width="30" style="vertical-align: middle;"></b><br>
|
||||||
|
<i>Version 2.0.0-alpha.1 | September 2025</i><br>
|
||||||
|
<a href="https://github.com/mzau/broke-cluster">🔮 Next: BROKE Cluster for multi-node deployments</a>
|
||||||
|
</p>
|
||||||
|
|||||||
+41
-1
@@ -166,6 +166,46 @@ Notes:
|
|||||||
|
|
||||||
**That's it!** Most tests (Category 1) use isolated caches and download small test models automatically (~12MB).
|
**That's it!** Most tests (Category 1) use isolated caches and download small test models automatically (~12MB).
|
||||||
|
|
||||||
|
### Enabling Issue #27 Tests (optional)
|
||||||
|
|
||||||
|
By default, several Issue #27 tests are skipped because they require a real multi‑shard safetensors model (with `model.safetensors.index.json`) in your user cache and enough free disk space to create an isolated copy.
|
||||||
|
|
||||||
|
- Set your user cache: `export MLXK2_USER_HF_HOME=/absolute/path/to/your/huggingface/cache`
|
||||||
|
- Ensure the cache contains a model with a safetensors index (common for larger Llama/Mistral models).
|
||||||
|
- Run the focused tests: `PYTHONPATH=. pytest tests_2.0/test_issue_27.py -v`
|
||||||
|
- If you see skips:
|
||||||
|
- “No safetensors index found” → pick a model that has `model.safetensors.index.json`.
|
||||||
|
- “Not enough free space” → free disk space; tests create a subset copy into an isolated temp cache.
|
||||||
|
- “User model not found” → verify the exact HF path in your cache and env var points to its `.../huggingface/cache` root.
|
||||||
|
|
||||||
|
With a suitable model present and `MLXK2_USER_HF_HOME` set, the Issue #27 tests should run without SKIPs.
|
||||||
|
|
||||||
|
### When Issue #27 real‑model tests make sense
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
- These tests validate the strict health policy against real upstream Hugging Face repositories that ship multi‑shard safetensors with a `model.safetensors.index.json`. They complement the deterministic unit tests by exercising real‑world layouts.
|
||||||
|
|
||||||
|
Run them when
|
||||||
|
- Your user cache contains at least one upstream PyTorch repo with a safetensors index (not MLX/GGUF conversions). Good candidates:
|
||||||
|
- `mistralai/Mistral-7B-Instruct-v0.2` or `-v0.3`
|
||||||
|
- `Qwen/Qwen1.5-7B-Chat`, `Qwen/Qwen2-7B-Instruct`
|
||||||
|
- `teknium/OpenHermes-2.5-Mistral`
|
||||||
|
- Gated: `meta-llama/Llama-2-7b-chat-hf`, `meta-llama/Llama-3-8B-Instruct`, `google/gemma-7b-it`
|
||||||
|
- You want to sanity‑check index‑based completeness, shard deletion/truncation, and LFS pointer detection against real artifacts.
|
||||||
|
|
||||||
|
They are not useful when
|
||||||
|
- Your cache only has MLX Community models (no `model.safetensors.index.json`) or GGUF models — the index‑based tests will skip by design. In that case, rely on `tests_2.0/test_health_multifile.py` for deterministic coverage.
|
||||||
|
|
||||||
|
Resource considerations
|
||||||
|
- Disk: tests copy a subset of files into an isolated cache. Tune size/speed with:
|
||||||
|
- `export MLXK2_COPY_STRATEGY="index_subset"`
|
||||||
|
- `export MLXK2_SUBSET_COUNT="1"`
|
||||||
|
- `export MLXK2_MIN_FREE_MB="512"` (or higher)
|
||||||
|
- Network: if you need to fetch a candidate model first, prefer downloading only `config.json`, `model.safetensors.index.json`, and 1–2 small shards to keep it light.
|
||||||
|
|
||||||
|
Summary
|
||||||
|
- If you have a suitable upstream PyTorch chat/instruct model with an index in your user cache, enable the env vars above and run `tests_2.0/test_issue_27.py` for an extra layer of real‑model assurance. Otherwise, the deterministic tests already validate the policy thoroughly.
|
||||||
|
|
||||||
### Optional Setup (Server Tests Only)
|
### Optional Setup (Server Tests Only)
|
||||||
|
|
||||||
For server tests (`@pytest.mark.server` - **excluded by default**):
|
For server tests (`@pytest.mark.server` - **excluded by default**):
|
||||||
@@ -186,7 +226,7 @@ To keep results reproducible and caches safe on Apple Silicon:
|
|||||||
- Preferred Python/venv: Apple‑native 3.9 in a dedicated env
|
- Preferred Python/venv: Apple‑native 3.9 in a dedicated env
|
||||||
- Example: `python3.9 -m venv venv39 && source venv39/bin/activate && pip install -e .[test]`
|
- Example: `python3.9 -m venv venv39 && source venv39/bin/activate && pip install -e .[test]`
|
||||||
- User cache (persistent): shared, real cache for manual ops and certain advanced/server tests
|
- User cache (persistent): shared, real cache for manual ops and certain advanced/server tests
|
||||||
- Project default: `export HF_HOME=/Volumes/mz-SSD/huggingface/cache`
|
- Example (external SSD): `export HF_HOME="/Volumes/SomeExternalSSD/models"`
|
||||||
- Safe ops: `list`, `health`, `show`; Coordinate `pull`/`rm` (maintenance window)
|
- Safe ops: `list`, `health`, `show`; Coordinate `pull`/`rm` (maintenance window)
|
||||||
- Test cache (isolated/default): ephemeral via fixtures; default `pytest` runs must not force the user cache
|
- Test cache (isolated/default): ephemeral via fixtures; default `pytest` runs must not force the user cache
|
||||||
- Category 1 tests use temporary caches and should not depend on `HF_HOME`
|
- Category 1 tests use temporary caches and should not depend on `HF_HOME`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ADR-001: MLX-Knife 2.0 Migration Path to JSON-First Architecture
|
# ADR-001: MLX-Knife 2.0 Migration Path to JSON-First Architecture
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
**Accepted & Implemented** - 2025-08-28
|
**Accepted** - 2025-08-28
|
||||||
|
|
||||||
**Implementation Status:**
|
**Implementation Status:**
|
||||||
- ✅ Clean-room 2.0 implementation complete (Sessions 1-3)
|
- ✅ Clean-room 2.0 implementation complete (Sessions 1-3)
|
||||||
|
|||||||
+2
-8
@@ -8,8 +8,8 @@ This directory contains Architecture Decision Records (ADRs) that document signi
|
|||||||
|
|
||||||
| ADR | Title | Status | Date |
|
| ADR | Title | Status | Date |
|
||||||
|-----|-------|--------|------|
|
|-----|-------|--------|------|
|
||||||
| [ADR-001](ADR-001-json-api-strategy.md) | JSON API Strategy & 2.0 Migration Path | Proposed | 2025-08-26 |
|
| [ADR-001](ADR-001-json-api-strategy.md) | JSON API Strategy & 2.0 Migration Path | Accepted | 2025-08-28 |
|
||||||
| [ADR-002](ADR-002-edge-cases.md) | Edge Cases from 1.x Test Suite | Proposed | 2025-08-26 |
|
| [ADR-002](ADR-002-edge-cases.md) | Edge Cases from 1.x Test Suite | Accepted | 2025-08-28 |
|
||||||
|
|
||||||
## ADR Format
|
## ADR Format
|
||||||
|
|
||||||
@@ -18,9 +18,3 @@ Each ADR follows this structure:
|
|||||||
- **Context**: Why this decision is needed
|
- **Context**: Why this decision is needed
|
||||||
- **Decision**: What we decided to do
|
- **Decision**: What we decided to do
|
||||||
- **Consequences**: What happens as a result
|
- **Consequences**: What happens as a result
|
||||||
|
|
||||||
## Related Documents
|
|
||||||
|
|
||||||
- [2.0 Implementation Plan](../development/2.0-implementation-plan.md)
|
|
||||||
- [GitHub Issue #8](https://github.com/mzau/mlx-knife/issues/8) - JSON API Feature Request
|
|
||||||
- [Refactoring Analysis](../development/refactoring-analysis.md) - Why we chose clean-room over refactoring
|
|
||||||
+1
-1
@@ -7,4 +7,4 @@ import warnings
|
|||||||
# Issue parity with 1.1.0 (Issue #22)
|
# Issue parity with 1.1.0 (Issue #22)
|
||||||
warnings.filterwarnings('ignore', message='urllib3 v2 only supports OpenSSL 1.1.1+')
|
warnings.filterwarnings('ignore', message='urllib3 v2 only supports OpenSSL 1.1.1+')
|
||||||
|
|
||||||
__version__ = "2.0.0-alpha"
|
__version__ = "2.0.0-alpha.1"
|
||||||
|
|||||||
+40
-13
@@ -13,6 +13,13 @@ from .operations.pull import pull_operation
|
|||||||
from .operations.rm import rm_operation
|
from .operations.rm import rm_operation
|
||||||
from .operations.show import show_model_operation
|
from .operations.show import show_model_operation
|
||||||
from .spec import JSON_API_SPEC_VERSION
|
from .spec import JSON_API_SPEC_VERSION
|
||||||
|
from .output.human import (
|
||||||
|
render_list,
|
||||||
|
render_health,
|
||||||
|
render_show,
|
||||||
|
render_pull,
|
||||||
|
render_rm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_json_output(data: Dict[str, Any]) -> str:
|
def format_json_output(data: Dict[str, Any]) -> str:
|
||||||
@@ -49,6 +56,10 @@ def main():
|
|||||||
# List command
|
# List command
|
||||||
list_parser = subparsers.add_parser("list", help="List all cached models")
|
list_parser = subparsers.add_parser("list", help="List all cached models")
|
||||||
list_parser.add_argument("pattern", nargs="?", help="Filter models by pattern (optional)")
|
list_parser.add_argument("pattern", nargs="?", help="Filter models by pattern (optional)")
|
||||||
|
# Human-output modifiers (JSON output remains unchanged)
|
||||||
|
list_parser.add_argument("--all", action="store_true", dest="show_all", help="Show all details (human output)")
|
||||||
|
list_parser.add_argument("--health", action="store_true", dest="show_health", help="Include health column (human output)")
|
||||||
|
list_parser.add_argument("--verbose", action="store_true", help="Verbose details (human output)")
|
||||||
list_parser.add_argument("--json", action="store_true", help="Output in JSON format")
|
list_parser.add_argument("--json", action="store_true", help="Output in JSON format")
|
||||||
|
|
||||||
# Health command
|
# Health command
|
||||||
@@ -94,33 +105,49 @@ def main():
|
|||||||
print(f"mlxk2 {__version__}")
|
print(f"mlxk2 {__version__}")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# In alpha version, --json flag is required for broke-cluster compatibility
|
# Execute command and render per mode
|
||||||
if args.command and not hasattr(args, 'json'):
|
if args.command == "list":
|
||||||
result = handle_error("CommandError", "Internal error: --json flag not found")
|
|
||||||
elif args.command and not args.json:
|
|
||||||
result = handle_error("JsonRequired", "MLX-Knife 2.0-alpha requires --json flag. Use: mlxk2 " + args.command + " --json")
|
|
||||||
elif args.command == "list":
|
|
||||||
result = list_models(pattern=args.pattern)
|
result = list_models(pattern=args.pattern)
|
||||||
|
if args.json:
|
||||||
|
print(format_json_output(result))
|
||||||
|
else:
|
||||||
|
show_health = getattr(args, "show_health", False)
|
||||||
|
show_all = getattr(args, "show_all", False)
|
||||||
|
verbose = getattr(args, "verbose", False)
|
||||||
|
print(render_list(result, show_health=show_health, show_all=show_all, verbose=verbose))
|
||||||
elif args.command == "health":
|
elif args.command == "health":
|
||||||
result = health_check_operation(args.model)
|
result = health_check_operation(args.model)
|
||||||
|
if args.json:
|
||||||
|
print(format_json_output(result))
|
||||||
|
else:
|
||||||
|
print(render_health(result))
|
||||||
elif args.command == "show":
|
elif args.command == "show":
|
||||||
result = show_model_operation(args.model, args.files, args.config)
|
result = show_model_operation(args.model, args.files, args.config)
|
||||||
|
if args.json:
|
||||||
|
print(format_json_output(result))
|
||||||
|
else:
|
||||||
|
print(render_show(result))
|
||||||
elif args.command == "pull":
|
elif args.command == "pull":
|
||||||
result = pull_operation(args.model)
|
result = pull_operation(args.model)
|
||||||
|
if args.json:
|
||||||
|
print(format_json_output(result))
|
||||||
|
else:
|
||||||
|
print(render_pull(result))
|
||||||
elif args.command == "rm":
|
elif args.command == "rm":
|
||||||
result = rm_operation(args.model, args.force)
|
result = rm_operation(args.model, args.force)
|
||||||
|
if args.json:
|
||||||
|
print(format_json_output(result))
|
||||||
|
else:
|
||||||
|
print(render_rm(result))
|
||||||
elif args.command is None:
|
elif args.command is None:
|
||||||
result = handle_error("CommandError", "No command specified")
|
result = handle_error("CommandError", "No command specified")
|
||||||
|
print(format_json_output(result))
|
||||||
else:
|
else:
|
||||||
result = handle_error("CommandError", f"Unknown command: {args.command}")
|
result = handle_error("CommandError", f"Unknown command: {args.command}")
|
||||||
|
print(format_json_output(result))
|
||||||
print(format_json_output(result))
|
|
||||||
|
|
||||||
# Exit with appropriate code
|
# Exit with appropriate code
|
||||||
if result["status"] == "error":
|
sys.exit(0 if result.get("status") == "success" else 1)
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_result = handle_error("InternalError", str(e))
|
error_result = handle_error("InternalError", str(e))
|
||||||
|
|||||||
@@ -66,9 +66,20 @@ def _check_snapshot_health(model_path):
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
return False, "config.json contains invalid JSON"
|
return False, "config.json contains invalid JSON"
|
||||||
|
|
||||||
# If a multi-file safetensors index exists, enforce completeness
|
# Prefer safetensors index; else fall back to PyTorch index
|
||||||
index_file = model_path / "model.safetensors.index.json"
|
sft_index = model_path / "model.safetensors.index.json"
|
||||||
if index_file.exists():
|
pt_index = model_path / "pytorch_model.bin.index.json"
|
||||||
|
has_sft_files = any(model_path.rglob("*.safetensors"))
|
||||||
|
has_bin_files = any(model_path.rglob("*.bin"))
|
||||||
|
|
||||||
|
chosen_index = None
|
||||||
|
if sft_index.exists() and has_sft_files:
|
||||||
|
chosen_index = ("sft", sft_index)
|
||||||
|
elif pt_index.exists() and has_bin_files:
|
||||||
|
chosen_index = ("pt", pt_index)
|
||||||
|
|
||||||
|
if chosen_index is not None:
|
||||||
|
kind, index_file = chosen_index
|
||||||
try:
|
try:
|
||||||
with open(index_file) as f:
|
with open(index_file) as f:
|
||||||
index = json.load(f)
|
index = json.load(f)
|
||||||
@@ -98,7 +109,7 @@ def _check_snapshot_health(model_path):
|
|||||||
return False, f"LFS pointers instead of files: {', '.join(lfs_bad)}"
|
return False, f"LFS pointers instead of files: {', '.join(lfs_bad)}"
|
||||||
return True, "Multi-file model complete"
|
return True, "Multi-file model complete"
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
return False, "Invalid safetensors index file"
|
return False, "Invalid index file"
|
||||||
|
|
||||||
# No index: Check weight files (supports common formats)
|
# No index: Check weight files (supports common formats)
|
||||||
weight_files = (
|
weight_files = (
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def cleanup_model_locks(model_name):
|
|||||||
if lock_files:
|
if lock_files:
|
||||||
shutil.rmtree(locks_dir)
|
shutil.rmtree(locks_dir)
|
||||||
return len(lock_files)
|
return len(lock_files)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def humanize_size(num_bytes: Optional[int]) -> str:
|
||||||
|
if not isinstance(num_bytes, int):
|
||||||
|
return "-"
|
||||||
|
n = float(num_bytes)
|
||||||
|
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||||
|
if n < 1000:
|
||||||
|
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
||||||
|
n /= 1000.0
|
||||||
|
return f"{n:.1f}PB"
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_hash7(h: Optional[str]) -> str:
|
||||||
|
if not h:
|
||||||
|
return "-"
|
||||||
|
return h[:7]
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_time(iso_utc_z: Optional[str]) -> str:
|
||||||
|
if not iso_utc_z:
|
||||||
|
return "-"
|
||||||
|
try:
|
||||||
|
# Expected like 2025-08-30T12:34:56Z (UTC)
|
||||||
|
dt = datetime.strptime(iso_utc_z, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
delta = now - dt
|
||||||
|
seconds = int(delta.total_seconds())
|
||||||
|
|
||||||
|
if seconds < 45:
|
||||||
|
return "just now"
|
||||||
|
if seconds < 90:
|
||||||
|
return "1m ago"
|
||||||
|
minutes = round(seconds / 60)
|
||||||
|
if minutes < 45:
|
||||||
|
return f"{minutes}m ago"
|
||||||
|
if minutes < 90:
|
||||||
|
return "1h ago"
|
||||||
|
hours = round(minutes / 60)
|
||||||
|
if hours < 24:
|
||||||
|
return f"{hours}h ago"
|
||||||
|
if hours < 36:
|
||||||
|
return "1d ago"
|
||||||
|
days = round(hours / 24)
|
||||||
|
if days < 30:
|
||||||
|
return f"{days}d ago"
|
||||||
|
# For older entries, fall back to a compact date
|
||||||
|
return dt.strftime("%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
return iso_utc_z
|
||||||
|
|
||||||
|
|
||||||
|
def _table(rows: List[List[str]], headers: List[str]) -> str:
|
||||||
|
widths = [len(h) for h in headers]
|
||||||
|
for r in rows:
|
||||||
|
for i, cell in enumerate(r):
|
||||||
|
if i < len(widths):
|
||||||
|
widths[i] = max(widths[i], len(cell))
|
||||||
|
else:
|
||||||
|
widths.append(len(cell))
|
||||||
|
|
||||||
|
def fmt_row(cols: List[str]) -> str:
|
||||||
|
return " | ".join(col.ljust(widths[i]) for i, col in enumerate(cols))
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append(fmt_row(headers))
|
||||||
|
lines.append("-+-".join("-" * w for w in widths))
|
||||||
|
for r in rows:
|
||||||
|
lines.append(fmt_row(r))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_list(data: Dict[str, Any], show_health: bool, show_all: bool, verbose: bool) -> str:
|
||||||
|
models: List[Dict[str, Any]] = data.get("data", {}).get("models", [])
|
||||||
|
compact = (not show_all) and (not verbose)
|
||||||
|
if compact:
|
||||||
|
headers = ["Name", "Hash", "Size", "Modified", "Type"]
|
||||||
|
else:
|
||||||
|
headers = ["Name", "Hash", "Size", "Modified", "Framework", "Type"]
|
||||||
|
if show_health:
|
||||||
|
headers.append("Health")
|
||||||
|
|
||||||
|
# Human filter: by default only show MLX framework; with --all show everything
|
||||||
|
filtered: List[Dict[str, Any]] = []
|
||||||
|
for m in models:
|
||||||
|
if show_all or str(m.get("framework", "")).upper() == "MLX":
|
||||||
|
filtered.append(m)
|
||||||
|
|
||||||
|
rows: List[List[str]] = []
|
||||||
|
for m in filtered:
|
||||||
|
name = str(m.get("name", "-"))
|
||||||
|
if not verbose and name.startswith("mlx-community/"):
|
||||||
|
# Compact name without the default org prefix
|
||||||
|
name = name.split("/", 1)[1]
|
||||||
|
if compact:
|
||||||
|
row = [
|
||||||
|
name,
|
||||||
|
fmt_hash7(m.get("hash")),
|
||||||
|
humanize_size(m.get("size_bytes")),
|
||||||
|
fmt_time(m.get("last_modified")),
|
||||||
|
str(m.get("model_type", "-")),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
row = [
|
||||||
|
name,
|
||||||
|
fmt_hash7(m.get("hash")),
|
||||||
|
humanize_size(m.get("size_bytes")),
|
||||||
|
fmt_time(m.get("last_modified")),
|
||||||
|
str(m.get("framework", "-")),
|
||||||
|
str(m.get("model_type", "-")),
|
||||||
|
]
|
||||||
|
if show_health:
|
||||||
|
row.append(str(m.get("health", "-")))
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
# Note: show_all/verbose are reserved for future detail; table remains deterministic
|
||||||
|
return _table(rows, headers)
|
||||||
|
|
||||||
|
|
||||||
|
def render_health(data: Dict[str, Any]) -> str:
|
||||||
|
d = data.get("data", {})
|
||||||
|
summary = d.get("summary", {})
|
||||||
|
total = summary.get("total", 0)
|
||||||
|
healthy_count = summary.get("healthy_count", 0)
|
||||||
|
unhealthy_count = summary.get("unhealthy_count", 0)
|
||||||
|
|
||||||
|
lines = [f"Summary: total {total}, healthy {healthy_count}, unhealthy {unhealthy_count}"]
|
||||||
|
for entry in d.get("healthy", []):
|
||||||
|
lines.append(f"healthy {entry.get('name','-')} — {entry.get('reason','')}".rstrip())
|
||||||
|
for entry in d.get("unhealthy", []):
|
||||||
|
lines.append(f"unhealthy {entry.get('name','-')} — {entry.get('reason','')}".rstrip())
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_show(data: Dict[str, Any]) -> str:
|
||||||
|
d = data.get("data", {})
|
||||||
|
model = d.get("model", {})
|
||||||
|
name = model.get("name", "-")
|
||||||
|
h7 = fmt_hash7(model.get("hash"))
|
||||||
|
header = f"Model: {name}{('@'+h7) if h7 != '-' else ''}"
|
||||||
|
details = [
|
||||||
|
f"Framework: {model.get('framework','-')}",
|
||||||
|
f"Type: {model.get('model_type','-')}",
|
||||||
|
f"Size: {humanize_size(model.get('size_bytes'))}",
|
||||||
|
f"Modified: {fmt_time(model.get('last_modified'))}",
|
||||||
|
f"Health: {model.get('health','-')}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Optional sections
|
||||||
|
out: List[str] = [header, *details]
|
||||||
|
if "files" in d and isinstance(d["files"], list):
|
||||||
|
out.append("")
|
||||||
|
out.append("Files:")
|
||||||
|
for f in d["files"]:
|
||||||
|
out.append(f" - {f.get('name','?')} ({f.get('type','other')}, {f.get('size','?')})")
|
||||||
|
elif "config" in d and isinstance(d["config"], dict):
|
||||||
|
out.append("")
|
||||||
|
out.append("Config:")
|
||||||
|
for k, v in d["config"].items():
|
||||||
|
out.append(f" {k}: {v}")
|
||||||
|
elif d.get("metadata"):
|
||||||
|
out.append("")
|
||||||
|
out.append("Metadata:")
|
||||||
|
for k, v in d["metadata"].items():
|
||||||
|
out.append(f" {k}: {v}")
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def render_pull(data: Dict[str, Any]) -> str:
|
||||||
|
d = data.get("data", {})
|
||||||
|
status = data.get("status", "error")
|
||||||
|
model = d.get("model", "-")
|
||||||
|
msg = d.get("message", "")
|
||||||
|
if status == "success":
|
||||||
|
return f"pull: {model} — {msg}".rstrip()
|
||||||
|
err = data.get("error", {})
|
||||||
|
return f"pull: {model} — {err.get('message', msg)}".rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def render_rm(data: Dict[str, Any]) -> str:
|
||||||
|
d = data.get("data", {})
|
||||||
|
status = data.get("status", "error")
|
||||||
|
model = d.get("model", "-")
|
||||||
|
action = d.get("action", "-")
|
||||||
|
msg = d.get("message", "")
|
||||||
|
if status == "success":
|
||||||
|
return f"rm: {model} — {action}: {msg}".rstrip()
|
||||||
|
err = data.get("error", {})
|
||||||
|
return f"rm: {model} — {err.get('message', msg)}".rstrip()
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mlxk-json"
|
name = "mlxk-json"
|
||||||
version = "2.0.0-alpha"
|
dynamic = ["version"]
|
||||||
description = "MLX-Knife 2.0 - JSON-first model management for automation"
|
description = "MLX-Knife 2.0 - JSON-first model management for automation"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
+33
-56
@@ -2,7 +2,7 @@
|
|||||||
# Note: removed set -e to allow script to continue through all Python versions
|
# Note: removed set -e to allow script to continue through all Python versions
|
||||||
# Individual error handling is done explicitly in each test section
|
# Individual error handling is done explicitly in each test section
|
||||||
|
|
||||||
echo "🧪 MLX Knife Multi-Python Version Testing"
|
echo "🧪 MLX Knife 2.0 (mlxk2) Multi-Python Version Testing"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Prerequisites: Python versions should be available as:"
|
echo "Prerequisites: Python versions should be available as:"
|
||||||
echo " - python3 (3.9+ - system default)"
|
echo " - python3 (3.9+ - system default)"
|
||||||
@@ -52,26 +52,29 @@ test_python_version() {
|
|||||||
source "$venv_name/bin/activate"
|
source "$venv_name/bin/activate"
|
||||||
|
|
||||||
# Upgrade pip and install MLX Knife
|
# Upgrade pip and install MLX Knife
|
||||||
echo "📦 Installing MLX Knife..."
|
echo "📦 Installing MLX Knife (2.0) ..."
|
||||||
pip install --upgrade pip setuptools wheel > /dev/null 2>&1
|
local install_log="install_${version_name//./_}.log"
|
||||||
|
pip install --upgrade pip setuptools wheel > "$install_log" 2>&1
|
||||||
|
|
||||||
if pip install -e ".[dev,test]" > /dev/null 2>&1; then
|
if pip install -e ".[test]" >> "$install_log" 2>&1; then
|
||||||
echo -e "${GREEN}✅ Installation successful${NC}"
|
echo -e "${GREEN}✅ Installation successful${NC}"
|
||||||
|
echo "🧰 Ensuring tooling (ruff, mypy)..."
|
||||||
|
pip install -q "ruff>=0.1.0" "mypy>=1.5.0" >> "$install_log" 2>&1 || true
|
||||||
|
|
||||||
# Run smoke test
|
# Run smoke test
|
||||||
echo "🧪 Running import test (this may take up to 2 minutes for MLX)..."
|
echo "🧪 Running import test (mlxk2)..."
|
||||||
if python -c "import mlx_knife.cli; print('Import successful')"; then
|
if python -c "import mlxk2, mlxk2.cli; print('Import successful')"; then
|
||||||
echo -e "${GREEN}✅ Import test passed${NC}"
|
echo -e "${GREEN}✅ Import test passed${NC}"
|
||||||
|
|
||||||
# Try basic CLI command
|
# Try basic CLI command
|
||||||
echo "🧪 Testing CLI help..."
|
echo "🧪 Testing CLI version (JSON)..."
|
||||||
if python -m mlx_knife.cli --help > /dev/null 2>&1; then
|
if python -m mlxk2.cli --version --json > /dev/null 2>&1; then
|
||||||
echo -e "${GREEN}✅ CLI test passed${NC}"
|
echo -e "${GREEN}✅ CLI test (version) passed${NC}"
|
||||||
|
|
||||||
# Run complete test suite
|
# Run complete test suite
|
||||||
echo "🧪 Running FULL test suite (this takes 5-10 minutes)..."
|
echo "🧪 Running 2.0 test suite..."
|
||||||
local test_log="test_results_${version_name//./_}.log"
|
local test_log="test_results_${version_name//./_}.log"
|
||||||
if python -m pytest tests/ -v --tb=short > "$test_log" 2>&1; then
|
if python -m pytest tests_2.0/ -v --tb=short > "$test_log" 2>&1; then
|
||||||
local passed_count=$(grep -c "PASSED" "$test_log" 2>/dev/null)
|
local passed_count=$(grep -c "PASSED" "$test_log" 2>/dev/null)
|
||||||
local failed_count=$(grep -c "FAILED" "$test_log" 2>/dev/null)
|
local failed_count=$(grep -c "FAILED" "$test_log" 2>/dev/null)
|
||||||
passed_count=${passed_count:-0}
|
passed_count=${passed_count:-0}
|
||||||
@@ -82,51 +85,24 @@ test_python_version() {
|
|||||||
echo -e "${GREEN}✅ Full test suite passed ($passed_count/$test_count tests)${NC}"
|
echo -e "${GREEN}✅ Full test suite passed ($passed_count/$test_count tests)${NC}"
|
||||||
|
|
||||||
# Code quality checks
|
# Code quality checks
|
||||||
echo "🧪 Running code quality checks..."
|
echo "🧪 Running code quality checks (mlxk2)..."
|
||||||
|
|
||||||
# Check if ruff is properly installed
|
local ruff_log="ruff_${version_name//./_}.log"
|
||||||
if python -c "import ruff" > /dev/null 2>&1; then
|
echo "🧪 Running ruff check on mlxk2 (logging to $ruff_log)..."
|
||||||
local ruff_log="ruff_${version_name//./_}.log"
|
if python -m ruff check mlxk2/ > "$ruff_log" 2>&1; then
|
||||||
echo "🧪 Running ruff check (logging to $ruff_log)..."
|
echo -e "${GREEN}✅ ruff linting passed${NC}"
|
||||||
if python -m ruff check mlx_knife/ > "$ruff_log" 2>&1; then
|
|
||||||
echo -e "${GREEN}✅ ruff linting passed${NC}"
|
# Note: mypy might have many warnings, so we allow it to "fail" but still continue
|
||||||
|
python -m mypy mlxk2/ --ignore-missing-imports > mypy_${version_name//./_}.log 2>&1
|
||||||
# Note: mypy might have many warnings, so we allow it to "fail" but still continue
|
local mypy_errors=$(grep -c "error:" mypy_${version_name//./_}.log 2>/dev/null || echo "0")
|
||||||
python -m mypy mlx_knife/ --ignore-missing-imports > mypy_${version_name//./_}.log 2>&1
|
echo -e "${YELLOW}ℹ️ mypy check complete ($mypy_errors errors found)${NC}"
|
||||||
local mypy_errors=$(grep -c "error:" mypy_${version_name//./_}.log 2>/dev/null || echo "0")
|
|
||||||
echo -e "${YELLOW}ℹ️ mypy check complete ($mypy_errors errors found)${NC}"
|
RESULTS+=("${version_name}:FULL_SUCCESS:${passed_count}tests")
|
||||||
|
|
||||||
RESULTS+=("${version_name}:FULL_SUCCESS:${passed_count}tests")
|
|
||||||
else
|
|
||||||
local ruff_error_count=$(grep -c "Found .* error" "$ruff_log" 2>/dev/null || echo "unknown")
|
|
||||||
echo -e "${RED}❌ ruff linting failed ($ruff_error_count errors)${NC}"
|
|
||||||
echo " See $ruff_log for details"
|
|
||||||
RESULTS+=("${version_name}:RUFF_FAILED")
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ ruff not properly installed, trying to install...${NC}"
|
local ruff_error_count=$(grep -c "Found .* error" "$ruff_log" 2>/dev/null || echo "unknown")
|
||||||
if pip install ruff>=0.1.0 > /dev/null 2>&1; then
|
echo -e "${RED}❌ ruff linting failed ($ruff_error_count errors)${NC}"
|
||||||
echo "🔧 ruff installed, retrying check..."
|
echo " See $ruff_log for details"
|
||||||
local ruff_log="ruff_${version_name//./_}.log"
|
RESULTS+=("${version_name}:RUFF_FAILED")
|
||||||
if python -m ruff check mlx_knife/ > "$ruff_log" 2>&1; then
|
|
||||||
echo -e "${GREEN}✅ ruff linting passed${NC}"
|
|
||||||
|
|
||||||
# Note: mypy might have many warnings, so we allow it to "fail" but still continue
|
|
||||||
python -m mypy mlx_knife/ --ignore-missing-imports > mypy_${version_name//./_}.log 2>&1
|
|
||||||
local mypy_errors=$(grep -c "error:" mypy_${version_name//./_}.log 2>/dev/null || echo "0")
|
|
||||||
echo -e "${YELLOW}ℹ️ mypy check complete ($mypy_errors errors found)${NC}"
|
|
||||||
|
|
||||||
RESULTS+=("${version_name}:FULL_SUCCESS:${passed_count}tests")
|
|
||||||
else
|
|
||||||
local ruff_error_count=$(grep -c "Found .* error" "$ruff_log" 2>/dev/null || echo "unknown")
|
|
||||||
echo -e "${RED}❌ ruff linting failed after installation ($ruff_error_count errors)${NC}"
|
|
||||||
echo " See $ruff_log for details"
|
|
||||||
RESULTS+=("${version_name}:RUFF_FAILED")
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e "${RED}❌ Could not install ruff${NC}"
|
|
||||||
RESULTS+=("${version_name}:RUFF_INSTALL_FAILED")
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ Test suite failed ($passed_count passed, $failed_count failed)${NC}"
|
echo -e "${RED}❌ Test suite failed ($passed_count passed, $failed_count failed)${NC}"
|
||||||
@@ -147,6 +123,7 @@ test_python_version() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ Installation failed${NC}"
|
echo -e "${RED}❌ Installation failed${NC}"
|
||||||
|
echo " See $install_log for details"
|
||||||
RESULTS+=("${version_name}:INSTALL_FAILED")
|
RESULTS+=("${version_name}:INSTALL_FAILED")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -250,7 +227,7 @@ if [ $fully_verified_count -ge 2 ] && [ $failed_count -eq 0 ]; then
|
|||||||
echo " 1. Update README.md with verified Python versions"
|
echo " 1. Update README.md with verified Python versions"
|
||||||
echo " 2. Update pyproject.toml requires-python based on results"
|
echo " 2. Update pyproject.toml requires-python based on results"
|
||||||
echo " 3. Document verified versions: ${fully_verified_versions[*]}"
|
echo " 3. Document verified versions: ${fully_verified_versions[*]}"
|
||||||
echo " 4. Safe to tag and release MLX Knife 1.0-rc1"
|
echo " 4. Safe to tag and release (alpha.1)"
|
||||||
exit_code=0
|
exit_code=0
|
||||||
else
|
else
|
||||||
echo "🔧 WORK NEEDED:"
|
echo "🔧 WORK NEEDED:"
|
||||||
@@ -268,4 +245,4 @@ echo " - test_results_<version>.log: Detailed pytest results"
|
|||||||
echo " - mypy_<version>.log: Type checking results"
|
echo " - mypy_<version>.log: Type checking results"
|
||||||
echo " - Use these logs to debug specific compatibility issues"
|
echo " - Use these logs to debug specific compatibility issues"
|
||||||
|
|
||||||
exit $exit_code
|
exit $exit_code
|
||||||
|
|||||||
+22
-12
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
"""Test fixtures for MLX-Knife 2.0 isolated testing."""
|
"""Test fixtures for MLX-Knife 2.0 isolated testing."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -501,21 +503,25 @@ def copy_user_model_to_isolated(isolated_cache):
|
|||||||
|
|
||||||
# Helper: load index
|
# Helper: load index
|
||||||
def _load_index():
|
def _load_index():
|
||||||
idx = snapshots / "model.safetensors.index.json"
|
if target_snap is None:
|
||||||
if idx.exists():
|
return None
|
||||||
try:
|
sft_idx = target_snap / "model.safetensors.index.json"
|
||||||
return _json.loads(idx.read_text())
|
pt_idx = target_snap / "pytorch_model.bin.index.json"
|
||||||
except Exception:
|
for idx in (sft_idx, pt_idx):
|
||||||
return None
|
if idx.exists():
|
||||||
|
try:
|
||||||
|
return _json.loads(idx.read_text())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Helper: get referenced shard paths
|
# Helper: get referenced shard paths
|
||||||
def _referenced_shards():
|
def _referenced_shards():
|
||||||
index = _load_index()
|
index = _load_index()
|
||||||
if not index or not isinstance(index.get("weight_map"), dict):
|
if not index or not isinstance(index.get("weight_map"), dict) or target_snap is None:
|
||||||
return []
|
return []
|
||||||
files = sorted(set(index["weight_map"].values()))
|
files = sorted(set(index["weight_map"].values()))
|
||||||
return [model_dir / f for f in files]
|
return [target_snap / f for f in files]
|
||||||
|
|
||||||
for m in mutations_list:
|
for m in mutations_list:
|
||||||
if m == 'remove_config' and target_snap is not None:
|
if m == 'remove_config' and target_snap is not None:
|
||||||
@@ -603,8 +609,10 @@ def copy_user_model_to_isolated(isolated_cache):
|
|||||||
|
|
||||||
# Decide which files to copy
|
# Decide which files to copy
|
||||||
selected: list[Path] = []
|
selected: list[Path] = []
|
||||||
idx = src_snap / "model.safetensors.index.json"
|
sft_idx = src_snap / "model.safetensors.index.json"
|
||||||
if strategy == "index_subset" and idx.exists():
|
pt_idx = src_snap / "pytorch_model.bin.index.json"
|
||||||
|
idx = sft_idx if sft_idx.exists() else (pt_idx if pt_idx.exists() else None)
|
||||||
|
if strategy == "index_subset" and idx is not None and idx.exists():
|
||||||
try:
|
try:
|
||||||
index = _json.loads(idx.read_text())
|
index = _json.loads(idx.read_text())
|
||||||
wm = index.get("weight_map") or {}
|
wm = index.get("weight_map") or {}
|
||||||
@@ -626,8 +634,10 @@ def copy_user_model_to_isolated(isolated_cache):
|
|||||||
shard_files.sort()
|
shard_files.sort()
|
||||||
selected.extend(shard_files[:subset_count])
|
selected.extend(shard_files[:subset_count])
|
||||||
# include index if present
|
# include index if present
|
||||||
if idx.exists():
|
if sft_idx.exists():
|
||||||
selected.append(idx)
|
selected.append(sft_idx)
|
||||||
|
elif pt_idx.exists():
|
||||||
|
selected.append(pt_idx)
|
||||||
# Always include config.json if present
|
# Always include config.json if present
|
||||||
cfg = src_snap / "config.json"
|
cfg = src_snap / "config.json"
|
||||||
if cfg.exists():
|
if cfg.exists():
|
||||||
|
|||||||
@@ -99,3 +99,39 @@ def test_partial_tmp_marker_is_unhealthy(isolated_cache):
|
|||||||
from mlxk2.operations.health import health_check_operation
|
from mlxk2.operations.health import health_check_operation
|
||||||
result = health_check_operation("test/partial")
|
result = health_check_operation("test/partial")
|
||||||
assert any(m["name"] == "test/partial" and m["status"] == "unhealthy" for m in result["data"]["unhealthy"])
|
assert any(m["name"] == "test/partial" and m["status"] == "unhealthy" for m in result["data"]["unhealthy"])
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pt_idx(dir: Path, shards: list[str]):
|
||||||
|
idx = {
|
||||||
|
"metadata": {},
|
||||||
|
"weight_map": {f"layer{i}": shard for i, shard in enumerate(shards)}
|
||||||
|
}
|
||||||
|
(dir / "pytorch_model.bin.index.json").write_text(json.dumps(idx))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pytorch_index_missing_shard_is_unhealthy(isolated_cache):
|
||||||
|
snap = isolated_cache / "models--test--pt" / "snapshots" / "main"
|
||||||
|
snap.mkdir(parents=True)
|
||||||
|
shards = ["pytorch_model-00001-of-00002.bin", "pytorch_model-00002-of-00002.bin"]
|
||||||
|
_write_pt_idx(snap, shards)
|
||||||
|
# Create only one shard
|
||||||
|
(snap / shards[0]).write_bytes(b"ok")
|
||||||
|
(snap / "config.json").write_text(json.dumps({"model_type": "test"}))
|
||||||
|
|
||||||
|
from mlxk2.operations.health import health_check_operation
|
||||||
|
result = health_check_operation("test/pt")
|
||||||
|
assert any(m["name"] == "test/pt" and m["status"] == "unhealthy" for m in result["data"]["unhealthy"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pytorch_index_complete_is_healthy(isolated_cache):
|
||||||
|
snap = isolated_cache / "models--test--pt2" / "snapshots" / "main"
|
||||||
|
snap.mkdir(parents=True)
|
||||||
|
shards = ["pytorch_model-00001-of-00002.bin", "pytorch_model-00002-of-00002.bin"]
|
||||||
|
_write_pt_idx(snap, shards)
|
||||||
|
for s in shards:
|
||||||
|
(snap / s).write_bytes(b"ok")
|
||||||
|
(snap / "config.json").write_text(json.dumps({"model_type": "test"}))
|
||||||
|
|
||||||
|
from mlxk2.operations.health import health_check_operation
|
||||||
|
result = health_check_operation("test/pt2")
|
||||||
|
assert any(m["name"] == "test/pt2" and m["status"] == "healthy" for m in result["data"]["healthy"])
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from mlxk2.output.human import render_list, render_health
|
||||||
|
|
||||||
|
|
||||||
|
def sample_list_data():
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"command": "list",
|
||||||
|
"data": {
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "mlx-community/TinyChat",
|
||||||
|
"hash": "abcdef0123456789abcdef0123456789abcdef01",
|
||||||
|
"size_bytes": 1_234_567,
|
||||||
|
"last_modified": "2025-08-30T12:00:00Z",
|
||||||
|
"framework": "MLX",
|
||||||
|
"model_type": "chat",
|
||||||
|
"capabilities": ["text-generation", "chat"],
|
||||||
|
"health": "healthy",
|
||||||
|
"cached": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "other-org/some-gguf",
|
||||||
|
"hash": None,
|
||||||
|
"size_bytes": 2_000,
|
||||||
|
"last_modified": "2025-08-30T11:00:00Z",
|
||||||
|
"framework": "GGUF",
|
||||||
|
"model_type": "base",
|
||||||
|
"capabilities": ["text-generation"],
|
||||||
|
"health": "unhealthy",
|
||||||
|
"cached": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_human_compact_filters_and_headers():
|
||||||
|
out = render_list(sample_list_data(), show_health=False, show_all=False, verbose=False)
|
||||||
|
# No Framework column in compact mode
|
||||||
|
header = out.splitlines()[0]
|
||||||
|
assert "Framework" not in header
|
||||||
|
assert "Modified" in header
|
||||||
|
# Only MLX model should be shown, with compact name
|
||||||
|
assert "TinyChat" in out
|
||||||
|
assert "mlx-community/" not in out
|
||||||
|
assert "some-gguf" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_human_all_and_verbose_shows_framework_and_full_names():
|
||||||
|
out = render_list(sample_list_data(), show_health=False, show_all=True, verbose=True)
|
||||||
|
header = out.splitlines()[0]
|
||||||
|
assert "Framework" in header
|
||||||
|
assert "mlx-community/TinyChat" in out
|
||||||
|
assert "other-org/some-gguf" in out
|
||||||
|
# Framework labels present
|
||||||
|
assert "MLX" in out and "GGUF" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_human_summary_and_entries():
|
||||||
|
data = {
|
||||||
|
"status": "success",
|
||||||
|
"command": "health",
|
||||||
|
"data": {
|
||||||
|
"healthy": [
|
||||||
|
{"name": "model-a", "status": "healthy", "reason": "ok"}
|
||||||
|
],
|
||||||
|
"unhealthy": [
|
||||||
|
{"name": "model-b", "status": "unhealthy", "reason": "missing"}
|
||||||
|
],
|
||||||
|
"summary": {"total": 2, "healthy_count": 1, "unhealthy_count": 1},
|
||||||
|
},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
out = render_health(data)
|
||||||
|
assert "Summary: total 2, healthy 1, unhealthy 1" in out
|
||||||
|
assert "model-a" in out
|
||||||
|
assert "model-b" in out
|
||||||
|
|
||||||
@@ -57,9 +57,10 @@ class TestIssue27Exploration:
|
|||||||
monkeypatch.setenv("MLXK2_COPY_STRATEGY", "index_subset")
|
monkeypatch.setenv("MLXK2_COPY_STRATEGY", "index_subset")
|
||||||
monkeypatch.setenv("MLXK2_SUBSET_COUNT", "0")
|
monkeypatch.setenv("MLXK2_SUBSET_COUNT", "0")
|
||||||
dst = copy_user_model_to_isolated(model)
|
dst = copy_user_model_to_isolated(model)
|
||||||
idx = dst / 'model.safetensors.index.json'
|
sft_idx = dst / 'model.safetensors.index.json'
|
||||||
if not idx.exists():
|
pt_idx = dst / 'pytorch_model.bin.index.json'
|
||||||
pytest.skip('No safetensors index found; skipping index-missing-shards test')
|
if not sft_idx.exists() and not pt_idx.exists():
|
||||||
|
pytest.skip('No safetensors/pytorch index found; skipping index-missing-shards test')
|
||||||
|
|
||||||
from mlxk2.operations.health import health_check_operation
|
from mlxk2.operations.health import health_check_operation
|
||||||
result = health_check_operation(model)
|
result = health_check_operation(model)
|
||||||
@@ -71,8 +72,8 @@ class TestIssue27Exploration:
|
|||||||
)
|
)
|
||||||
dst = copy_user_model_to_isolated(model, mutations=['delete_indexed_shard'])
|
dst = copy_user_model_to_isolated(model, mutations=['delete_indexed_shard'])
|
||||||
# If no index exists, skip this targeted test
|
# If no index exists, skip this targeted test
|
||||||
if not (dst / 'model.safetensors.index.json').exists():
|
if not (dst / 'model.safetensors.index.json').exists() and not (dst / 'pytorch_model.bin.index.json').exists():
|
||||||
pytest.skip('No safetensors index found; skipping index-specific test')
|
pytest.skip('No safetensors/pytorch index found; skipping index-specific test')
|
||||||
|
|
||||||
from mlxk2.operations.health import health_check_operation
|
from mlxk2.operations.health import health_check_operation
|
||||||
result = health_check_operation(model)
|
result = health_check_operation(model)
|
||||||
@@ -83,8 +84,8 @@ class TestIssue27Exploration:
|
|||||||
"MLXK2_ISSUE27_MODEL", "mlx-community/Mistral-7B-Instruct-v0.2-4bit"
|
"MLXK2_ISSUE27_MODEL", "mlx-community/Mistral-7B-Instruct-v0.2-4bit"
|
||||||
)
|
)
|
||||||
dst = copy_user_model_to_isolated(model, mutations=['truncate_indexed_shard'])
|
dst = copy_user_model_to_isolated(model, mutations=['truncate_indexed_shard'])
|
||||||
if not (dst / 'model.safetensors.index.json').exists():
|
if not (dst / 'model.safetensors.index.json').exists() and not (dst / 'pytorch_model.bin.index.json').exists():
|
||||||
pytest.skip('No safetensors index found; skipping index-specific test')
|
pytest.skip('No safetensors/pytorch index found; skipping index-specific test')
|
||||||
|
|
||||||
from mlxk2.operations.health import health_check_operation
|
from mlxk2.operations.health import health_check_operation
|
||||||
result = health_check_operation(model)
|
result = health_check_operation(model)
|
||||||
@@ -95,8 +96,8 @@ class TestIssue27Exploration:
|
|||||||
"MLXK2_ISSUE27_MODEL", "mlx-community/Mistral-7B-Instruct-v0.2-4bit"
|
"MLXK2_ISSUE27_MODEL", "mlx-community/Mistral-7B-Instruct-v0.2-4bit"
|
||||||
)
|
)
|
||||||
dst = copy_user_model_to_isolated(model, mutations=['lfsify_indexed_shard'])
|
dst = copy_user_model_to_isolated(model, mutations=['lfsify_indexed_shard'])
|
||||||
if not (dst / 'model.safetensors.index.json').exists():
|
if not (dst / 'model.safetensors.index.json').exists() and not (dst / 'pytorch_model.bin.index.json').exists():
|
||||||
pytest.skip('No safetensors index found; skipping index-specific test')
|
pytest.skip('No safetensors/pytorch index found; skipping index-specific test')
|
||||||
|
|
||||||
from mlxk2.operations.health import health_check_operation
|
from mlxk2.operations.health import health_check_operation
|
||||||
result = health_check_operation(model)
|
result = health_check_operation(model)
|
||||||
|
|||||||
Reference in New Issue
Block a user