from __future__ import annotations import argparse import datetime as dt import json from pathlib import Path from typing import Any from common import iter_model_card_paths, localize_workspace_path, read_yaml_mapping ROOT = Path(__file__).resolve().parents[1] DEFAULT_REPORT = ROOT / "reports" / "model-storage.json" REQUIRED_MODEL_STATUSES = {"staging", "production"} def has_any(path: Path, patterns: list[str]) -> bool: return any(path.glob(pattern) for pattern in patterns) def has_weight_file(path: Path) -> bool: for file_path in path.iterdir() if path.exists() else []: if not file_path.is_file(): continue name = file_path.name.lower() if name.endswith((".safetensors", ".bin", ".gguf")): return True return False def check_gguf(path: Path, card: dict[str, Any]) -> dict[str, Any]: filename = card.get("filename") if not filename: return {"status": "failed", "reason": "filename is missing in model card"} file_path = path / str(filename) if not file_path.exists(): return {"status": "missing", "reason": f"file is missing: {file_path.relative_to(ROOT)}"} size = file_path.stat().st_size expected_size = card.get("file_size_bytes") if expected_size and size != int(expected_size): return { "status": "partial", "reason": f"size mismatch: {size} != {expected_size}", "size_bytes": size, "expected_size_bytes": expected_size, } return {"status": "ok", "size_bytes": size, "expected_size_bytes": expected_size} def check_hf_model(path: Path) -> dict[str, Any]: required = ["config.json"] missing = [name for name in required if not (path / name).exists()] index_path = path / "model.safetensors.index.json" missing_shards: list[str] = [] if index_path.exists(): try: index = json.loads(index_path.read_text(encoding="utf-8")) shard_names = sorted(set((index.get("weight_map") or {}).values())) missing_shards = [name for name in shard_names if not (path / name).exists()] except json.JSONDecodeError: return {"status": "failed", "reason": "model.safetensors.index.json is invalid"} has_weights = has_weight_file(path) has_tokenizer = has_any(path, ["tokenizer.json", "tokenizer.model", "vocab.json"]) if missing: return {"status": "missing", "reason": f"missing required file(s): {', '.join(missing)}"} if missing_shards: preview = ", ".join(missing_shards[:4]) suffix = f" and {len(missing_shards) - 4} more" if len(missing_shards) > 4 else "" return {"status": "partial", "reason": f"missing shard file(s): {preview}{suffix}"} if not has_weights: return {"status": "metadata-only", "reason": "model weights are missing"} if not has_tokenizer: return {"status": "partial", "reason": "tokenizer files are missing"} return {"status": "ok"} def check_diffusers_model(path: Path) -> dict[str, Any]: missing = [name for name in ["model_index.json"] if not (path / name).exists()] if missing: return {"status": "missing", "reason": f"missing required file(s): {', '.join(missing)}"} if not any(path.rglob("*.safetensors")) and not any(path.rglob("*.bin")): return {"status": "metadata-only", "reason": "diffusers weights are missing"} return {"status": "ok"} def check_adapter(path: Path) -> dict[str, Any]: if not path.exists(): return {"status": "missing", "reason": f"adapter path is missing: {path}"} if has_any(path, ["adapter_config.json", "*.safetensors", "*.bin"]): return {"status": "ok"} return {"status": "metadata-only", "reason": "adapter artifact files are missing"} def resolve_storage_path(raw_path: str, models_root: Path | None = None) -> Path: if models_root and raw_path.startswith("/models/"): return models_root / raw_path.removeprefix("/models/") return localize_workspace_path(raw_path) def check_card(path: Path, *, models_root: Path | None = None) -> dict[str, Any]: card = read_yaml_mapping(path) storage_path = resolve_storage_path(str(card.get("storage_path") or ""), models_root=models_root) model_status = str(card.get("status") or "draft") item = { "id": card.get("id"), "name": card.get("name"), "type": card.get("type"), "model_status": model_status, "format": card.get("format"), "quantization": card.get("quantization"), "runtime": (card.get("deployment") or {}).get("runtime"), "served_model_name": (card.get("deployment") or {}).get("served_model_name"), "filename": card.get("filename"), "storage_path": str(storage_path), "card_path": str(path.relative_to(ROOT)), "status": "missing", "reason": None, "required": model_status in REQUIRED_MODEL_STATUSES, } if not storage_path.exists(): item["reason"] = "storage path is missing" return item model_format = str(card.get("format") or "").lower() model_type = str(card.get("type") or "").lower() if model_format == "gguf": result = check_gguf(storage_path, card) elif model_format == "diffusers" or model_type == "image-diffusion-model": result = check_diffusers_model(storage_path) elif model_type == "lora-adapter": result = check_adapter(storage_path) else: result = check_hf_model(storage_path) item.update(result) return item def main() -> int: parser = argparse.ArgumentParser(description="Check local model storage against model cards.") parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) parser.add_argument("--print", action="store_true") parser.add_argument("--no-report", action="store_true") parser.add_argument("--warn-only", action="store_true", help="Return success even when models are partial or missing.") parser.add_argument("--strict", action="store_true", help="Fail on incomplete draft/candidate models too.") parser.add_argument( "--models-root", type=Path, default=None, help="Override /models paths, for example Z:/LLM/models or /models inside the GPU host container.", ) args = parser.parse_args() models_root = args.models_root.resolve() if args.models_root else None models = [check_card(path, models_root=models_root) for path in iter_model_card_paths()] incomplete_statuses = {"failed", "missing", "partial"} incomplete = [model["id"] for model in models if model["status"] in incomplete_statuses] required_failed = [ model["id"] for model in models if model["required"] and model["status"] in incomplete_statuses ] failed = incomplete if args.strict else required_failed report = { "created_at": dt.datetime.now(dt.UTC).isoformat(), "models_root": str(models_root) if models_root else None, "status": "failed" if failed else "ok", "failed": failed, "required_failed": required_failed, "planned_incomplete": [ model["id"] for model in models if not model["required"] and model["status"] in incomplete_statuses ], "strict": args.strict, "counts": { "ok": sum(1 for model in models if model["status"] == "ok"), "missing": sum(1 for model in models if model["status"] == "missing"), "partial": sum(1 for model in models if model["status"] == "partial"), "metadata_only": sum(1 for model in models if model["status"] == "metadata-only"), "failed": sum(1 for model in models if model["status"] == "failed"), }, "models": models, } if not args.no_report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if args.print: print(json.dumps(report, ensure_ascii=False, indent=2)) else: print(f"Model storage status: {report['status']}") return 0 if args.warn_only else 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())