@tuned-tensor/cli 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import tarfile
7
+ import time
8
+ from pathlib import Path
9
+ from tempfile import TemporaryDirectory
10
+ from typing import Any
11
+
12
+ from model_contract import (
13
+ CERTIFIED_BASE_MODEL,
14
+ assert_certified_model_config,
15
+ )
16
+
17
+ MAX_ARCHIVE_MEMBERS = 20_000
18
+ MAX_ARCHIVE_EXPANDED_BYTES = 20 * 1024 * 1024 * 1024
19
+
20
+
21
+ def load_json(path: Path) -> dict[str, Any]:
22
+ value = json.loads(path.read_text(encoding="utf-8"))
23
+ if not isinstance(value, dict):
24
+ raise ValueError(f"Expected a JSON object in {path}")
25
+ return value
26
+
27
+
28
+ def configure_hugging_face_cache(cache_home: str | None) -> None:
29
+ """Treat model_cache as HF_HOME and keep legacy overrides consistent."""
30
+ if not cache_home:
31
+ return
32
+ home = Path(cache_home).expanduser().absolute()
33
+ hub = home / "hub"
34
+ os.environ.update({
35
+ "HF_HOME": str(home),
36
+ "HF_HUB_CACHE": str(hub),
37
+ "HUGGINGFACE_HUB_CACHE": str(hub),
38
+ })
39
+ for deprecated in (
40
+ "TRANSFORMERS_CACHE",
41
+ "PYTORCH_TRANSFORMERS_CACHE",
42
+ "PYTORCH_PRETRAINED_BERT_CACHE",
43
+ ):
44
+ os.environ.pop(deprecated, None)
45
+
46
+
47
+ def import_runtime_dependencies() -> None:
48
+ """Import libraries only after Hugging Face cache settings are final."""
49
+ global torch, PeftModel, AutoModelForCausalLM, AutoTokenizer
50
+
51
+ import torch as torch_module
52
+ from peft import PeftModel as peft_model
53
+ from transformers import (
54
+ AutoModelForCausalLM as auto_model_for_causal_lm,
55
+ AutoTokenizer as auto_tokenizer,
56
+ )
57
+
58
+ torch = torch_module
59
+ PeftModel = peft_model
60
+ AutoModelForCausalLM = auto_model_for_causal_lm
61
+ AutoTokenizer = auto_tokenizer
62
+
63
+
64
+ def strip_file_uri(value: str | None) -> str | None:
65
+ if not value:
66
+ return value
67
+ if value.startswith("file://"):
68
+ return value[7:]
69
+ return value
70
+
71
+
72
+ def _extract_adapter_archive(path: Path, destination: Path) -> None:
73
+ destination.mkdir(parents=True, exist_ok=True)
74
+ destination_root = destination.resolve()
75
+ with tarfile.open(path, "r:gz") as archive:
76
+ members = archive.getmembers()
77
+ if len(members) > MAX_ARCHIVE_MEMBERS:
78
+ raise ValueError(f"Model archive exceeds {MAX_ARCHIVE_MEMBERS} members")
79
+ expanded_bytes = sum(max(0, member.size) for member in members if member.isfile())
80
+ if expanded_bytes > MAX_ARCHIVE_EXPANDED_BYTES:
81
+ raise ValueError("Model archive exceeds the 20 GiB expanded-size limit")
82
+ for member in members:
83
+ member_path = (destination / member.name).resolve()
84
+ try:
85
+ member_path.relative_to(destination_root)
86
+ except ValueError as exc:
87
+ raise ValueError(f"Unsafe archive member: {member.name}") from exc
88
+ if member.issym() or member.islnk() or member.isdev():
89
+ raise ValueError(f"Unsafe archive member type: {member.name}")
90
+ archive.extractall(destination)
91
+
92
+
93
+ def resolve_adapter_path(value: str | None, tmp: Path) -> str | None:
94
+ path_value = strip_file_uri(value)
95
+ if not path_value:
96
+ return None
97
+ path = Path(path_value)
98
+ if path.is_file() and path.name.endswith(".tar.gz"):
99
+ extracted = tmp / "adapter"
100
+ _extract_adapter_archive(path, extracted)
101
+ candidates = sorted(extracted.rglob("adapter_config.json"))
102
+ if candidates:
103
+ return str(candidates[0].parent)
104
+ raise ValueError(f"Adapter archive contains no adapter_config.json: {path}")
105
+ if not path.is_dir():
106
+ raise ValueError(f"Adapter path is not a directory or .tar.gz archive: {path}")
107
+ return str(path)
108
+
109
+
110
+ def format_prompt(
111
+ tokenizer: Any,
112
+ system: str,
113
+ prompt: str,
114
+ ) -> str:
115
+ messages = [
116
+ {"role": "system", "content": system},
117
+ {"role": "user", "content": prompt},
118
+ ]
119
+ try:
120
+ return tokenizer.apply_chat_template(
121
+ messages,
122
+ tokenize=False,
123
+ add_generation_prompt=True,
124
+ )
125
+ except Exception as exc:
126
+ raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
127
+
128
+
129
+ def resolve_device(device: str) -> str:
130
+ if device not in {"cuda", "cpu"}:
131
+ raise ValueError(f"device must be cuda or cpu; got {device!r}")
132
+ if device == "cuda" and not torch.cuda.is_available():
133
+ raise RuntimeError("CUDA evaluation was requested, but torch.cuda.is_available() is false")
134
+ return device
135
+
136
+
137
+ def _assert_certified_model_source(base_model: str) -> None:
138
+ if not Path(base_model).exists() and base_model != CERTIFIED_BASE_MODEL:
139
+ raise ValueError(
140
+ f"The bundled evaluator currently certifies only {CERTIFIED_BASE_MODEL}; got {base_model!r}"
141
+ )
142
+
143
+
144
+ def load_text_model(payload: dict[str, Any], adapter_path: str | None):
145
+ base_model = str(payload["base_model"])
146
+ _assert_certified_model_source(base_model)
147
+ device = resolve_device(str(payload.get("device", "cuda")))
148
+ token = os.getenv("HF_TOKEN")
149
+ revision = payload.get("base_model_revision")
150
+ source_kwargs: dict[str, Any] = {
151
+ "trust_remote_code": False,
152
+ "token": token,
153
+ }
154
+ if not Path(base_model).exists():
155
+ source_kwargs["local_files_only"] = True
156
+ if revision and not Path(base_model).exists():
157
+ source_kwargs["revision"] = revision
158
+
159
+ tokenizer = AutoTokenizer.from_pretrained(base_model, **source_kwargs)
160
+ if tokenizer.eos_token_id is None:
161
+ raise ValueError(f"{CERTIFIED_BASE_MODEL} tokenizer has no EOS token")
162
+ if tokenizer.pad_token_id is None:
163
+ tokenizer.pad_token = tokenizer.eos_token
164
+
165
+ dtype = None
166
+ if device == "cuda":
167
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
168
+
169
+ model_kwargs: dict[str, Any] = dict(source_kwargs)
170
+ if dtype is not None:
171
+ model_kwargs["dtype"] = dtype
172
+ if device == "cuda":
173
+ model_kwargs["device_map"] = {"": torch.cuda.current_device()}
174
+
175
+ model = AutoModelForCausalLM.from_pretrained(base_model, **model_kwargs)
176
+ assert_certified_model_config(model.config)
177
+ if adapter_path:
178
+ model = PeftModel.from_pretrained(model, adapter_path)
179
+ if device != "cuda":
180
+ model = model.to(device)
181
+ model.eval()
182
+ return model, tokenizer, device
183
+
184
+
185
+ def sampling_kwargs(generation: dict[str, Any]) -> dict[str, Any]:
186
+ temperature = float(generation.get("temperature", 0))
187
+ if temperature <= 0:
188
+ return {"do_sample": False}
189
+ return {
190
+ "do_sample": True,
191
+ "temperature": temperature,
192
+ "top_p": float(generation.get("top_p", 1)),
193
+ }
194
+
195
+
196
+ def generate_text_one(
197
+ model: Any,
198
+ tokenizer: Any,
199
+ system: str,
200
+ example: dict[str, Any],
201
+ generation: dict[str, Any],
202
+ ) -> dict[str, Any]:
203
+ if example.get("input_assets"):
204
+ raise ValueError("The bundled Qwen SFT evaluator is text-only and does not accept input_assets")
205
+ prompt = str(example["input"])
206
+ formatted = format_prompt(tokenizer, system, prompt)
207
+ inputs = tokenizer(formatted, return_tensors="pt")
208
+ target_device = next(model.parameters()).device
209
+ inputs = {key: value.to(target_device) for key, value in inputs.items()}
210
+ started = time.perf_counter()
211
+ with torch.inference_mode():
212
+ generated = model.generate(
213
+ **inputs,
214
+ max_new_tokens=int(generation.get("max_new_tokens", 256)),
215
+ pad_token_id=tokenizer.eos_token_id,
216
+ **sampling_kwargs(generation),
217
+ )
218
+ latency_ms = max(0, round((time.perf_counter() - started) * 1000))
219
+ input_length = int(inputs["input_ids"].shape[-1])
220
+ output_tokens = generated[0][input_length:]
221
+ actual = tokenizer.decode(output_tokens, skip_special_tokens=True).strip()
222
+ return {
223
+ "id": str(example["id"]),
224
+ "actual": actual,
225
+ "latency_ms": latency_ms,
226
+ }
227
+
228
+
229
+ def main() -> None:
230
+ parser = argparse.ArgumentParser()
231
+ parser.add_argument("--input", required=True)
232
+ parser.add_argument("--output", required=True)
233
+ args = parser.parse_args()
234
+
235
+ payload = load_json(Path(args.input))
236
+ if payload.get("protocol_version") != 2:
237
+ raise ValueError("Unsupported inference protocol; expected protocol_version 2")
238
+ if payload.get("model_loader") not in (None, "causal_lm"):
239
+ raise ValueError("The bundled evaluator is text-only and requires model_loader=causal_lm")
240
+ configure_hugging_face_cache(payload.get("model_cache"))
241
+ import_runtime_dependencies()
242
+
243
+ with TemporaryDirectory(prefix="tt-local-evaluate-") as tmp_dir:
244
+ adapter_path = resolve_adapter_path(payload.get("adapter_path"), Path(tmp_dir))
245
+ generation = payload.get("generation", {})
246
+ if not isinstance(generation, dict):
247
+ raise ValueError("generation must be a JSON object")
248
+ model, tokenizer, _device = load_text_model(payload, adapter_path)
249
+ results = [
250
+ generate_text_one(
251
+ model,
252
+ tokenizer,
253
+ str(payload.get("system", "")),
254
+ example,
255
+ generation,
256
+ )
257
+ for example in payload.get("examples", [])
258
+ ]
259
+
260
+ output = {
261
+ "provider": "transformers",
262
+ "model_id": payload.get("model_id"),
263
+ "base_model": payload.get("base_model"),
264
+ "adapter_path": payload.get("adapter_path"),
265
+ "generation_config": generation,
266
+ "results": results,
267
+ }
268
+ output_path = Path(args.output)
269
+ output_path.parent.mkdir(parents=True, exist_ok=True)
270
+ output_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ CERTIFIED_BASE_MODEL = "Qwen/Qwen3.5-2B"
7
+ CERTIFIED_TEXT_CONFIG = {
8
+ "model_type": "qwen3_5_text",
9
+ "hidden_size": 2048,
10
+ "num_hidden_layers": 24,
11
+ "num_attention_heads": 8,
12
+ "num_key_value_heads": 2,
13
+ "intermediate_size": 6144,
14
+ "vocab_size": 248320,
15
+ }
16
+
17
+
18
+ def _field(value: Any, name: str) -> Any:
19
+ if isinstance(value, dict):
20
+ return value.get(name)
21
+ return getattr(value, name, None)
22
+
23
+
24
+ def assert_certified_model_config(value: Any, label: str = "base-model config") -> None:
25
+ model_type = _field(value, "model_type")
26
+ architectures = _field(value, "architectures")
27
+ if model_type == "qwen3_5":
28
+ text_config = _field(value, "text_config")
29
+ if (
30
+ not isinstance(architectures, (list, tuple))
31
+ or "Qwen3_5ForConditionalGeneration" not in architectures
32
+ or text_config is None
33
+ ):
34
+ raise ValueError(f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture")
35
+ elif model_type == "qwen3_5_text":
36
+ # AutoModelForCausalLM exposes the selected text sub-config after it
37
+ # dispatches the verified repository-level Qwen3.5 config.
38
+ text_config = value
39
+ else:
40
+ raise ValueError(f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture")
41
+ for name, expected in CERTIFIED_TEXT_CONFIG.items():
42
+ actual = _field(text_config, name)
43
+ if actual != expected:
44
+ raise ValueError(
45
+ f"{label} is not the certified {CERTIFIED_BASE_MODEL} architecture: "
46
+ f"text_config.{name} must be {expected!r}, got {actual!r}"
47
+ )
@@ -0,0 +1,192 @@
1
+ import argparse
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from model_contract import (
10
+ CERTIFIED_BASE_MODEL,
11
+ assert_certified_model_config,
12
+ )
13
+
14
+ ALLOW_PATTERNS = [
15
+ "*.json",
16
+ "*.model",
17
+ "*.safetensors",
18
+ "*.safetensors.index.json",
19
+ "*.tiktoken",
20
+ "*.txt",
21
+ "chat_template.jinja",
22
+ "merges.txt",
23
+ "pytorch_model*.bin",
24
+ "sentencepiece.bpe.model",
25
+ "spiece.model",
26
+ "tokenizer.*",
27
+ "vocab.*",
28
+ ]
29
+
30
+ IGNORE_PATTERNS = [
31
+ "*.ckpt",
32
+ "*.gguf",
33
+ "*.h5",
34
+ "*.msgpack",
35
+ "*.onnx",
36
+ "*.ot",
37
+ "optimizer.pt",
38
+ "rng_state*.pth",
39
+ "scheduler.pt",
40
+ "trainer_state.json",
41
+ "training_args.bin",
42
+ ]
43
+
44
+
45
+ def write_json(path: str, value: dict[str, Any]) -> None:
46
+ destination = Path(path)
47
+ destination.parent.mkdir(parents=True, exist_ok=True)
48
+ destination.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
49
+
50
+
51
+ def configure_hugging_face_cache(cache_home: str | None) -> None:
52
+ """Treat model_cache as HF_HOME and keep legacy overrides consistent."""
53
+ if not cache_home:
54
+ return
55
+ # Keep the lexical absolute path supplied by the Node runner. Path.resolve
56
+ # canonicalizes macOS /var to /private/var, making metadata disagree with
57
+ # training even though both locations identify the same directory.
58
+ home = Path(cache_home).expanduser().absolute()
59
+ hub = home / "hub"
60
+ os.environ.update({
61
+ "HF_HOME": str(home),
62
+ "HF_HUB_CACHE": str(hub),
63
+ "HUGGINGFACE_HUB_CACHE": str(hub),
64
+ })
65
+ for deprecated in (
66
+ "TRANSFORMERS_CACHE",
67
+ "PYTORCH_TRANSFORMERS_CACHE",
68
+ "PYTORCH_PRETRAINED_BERT_CACHE",
69
+ ):
70
+ os.environ.pop(deprecated, None)
71
+
72
+
73
+ def hash_file(path: Path, algorithm: str, prefix: bytes = b"") -> str:
74
+ digest = hashlib.new(algorithm)
75
+ digest.update(prefix)
76
+ with path.open("rb") as source:
77
+ for chunk in iter(lambda: source.read(8 * 1024 * 1024), b""):
78
+ digest.update(chunk)
79
+ return digest.hexdigest()
80
+
81
+
82
+ def verify_snapshot(snapshot: Path) -> tuple[list[Path], int]:
83
+ config = snapshot / "config.json"
84
+ if not config.is_file() or config.stat().st_size == 0:
85
+ raise ValueError(f"Cached snapshot is missing a non-empty config.json: {snapshot}")
86
+ try:
87
+ parsed_config = json.loads(config.read_text(encoding="utf-8"))
88
+ assert_certified_model_config(parsed_config, f"Cached config {config}")
89
+ except (OSError, json.JSONDecodeError) as exc:
90
+ raise ValueError(f"Cached snapshot has an invalid config.json: {snapshot}") from exc
91
+
92
+ weight_files = sorted(snapshot.glob("*.safetensors")) + sorted(snapshot.glob("pytorch_model*.bin"))
93
+ index_files = sorted(snapshot.glob("*.safetensors.index.json")) + sorted(snapshot.glob("pytorch_model*.bin.index.json"))
94
+ for index in index_files:
95
+ try:
96
+ weight_map = json.loads(index.read_text(encoding="utf-8")).get("weight_map", {})
97
+ except (OSError, json.JSONDecodeError) as exc:
98
+ raise ValueError(f"Cached snapshot has an invalid weight index: {index}") from exc
99
+ if not isinstance(weight_map, dict) or not weight_map:
100
+ raise ValueError(f"Cached snapshot has an empty weight index: {index}")
101
+ for name in set(weight_map.values()):
102
+ if not isinstance(name, str) or not (snapshot / name).is_file():
103
+ raise ValueError(f"Cached snapshot is missing an indexed weight shard: {name}")
104
+ if not weight_files:
105
+ raise ValueError(f"Cached snapshot contains no Transformers model weights: {snapshot}")
106
+ for weight in weight_files:
107
+ if weight.stat().st_size == 0:
108
+ raise ValueError(f"Cached snapshot contains an empty model weight: {weight}")
109
+
110
+ tokenizer_config = snapshot / "tokenizer_config.json"
111
+ vocabulary = [
112
+ snapshot / "tokenizer.json",
113
+ snapshot / "tokenizer.model",
114
+ snapshot / "sentencepiece.bpe.model",
115
+ snapshot / "spiece.model",
116
+ snapshot / "vocab.json",
117
+ snapshot / "tokenizer.tiktoken",
118
+ ]
119
+ if not tokenizer_config.is_file() or not any(path.is_file() and path.stat().st_size > 0 for path in vocabulary):
120
+ raise ValueError(f"Cached snapshot is missing tokenizer metadata or vocabulary files: {snapshot}")
121
+
122
+ files = [path for path in snapshot.rglob("*") if path.is_file()]
123
+ verified_blobs = 0
124
+ for path in files:
125
+ blob = path.resolve()
126
+ expected = blob.name.lower()
127
+ if len(expected) == 64 and all(character in "0123456789abcdef" for character in expected):
128
+ actual = hash_file(blob, "sha256")
129
+ elif len(expected) == 40 and all(character in "0123456789abcdef" for character in expected):
130
+ actual = hash_file(blob, "sha1", f"blob {blob.stat().st_size}\0".encode())
131
+ else:
132
+ continue
133
+ if actual != expected:
134
+ raise ValueError(f"Cached Hugging Face blob checksum mismatch: {path}")
135
+ verified_blobs += 1
136
+ return files, verified_blobs
137
+
138
+
139
+ def main() -> None:
140
+ parser = argparse.ArgumentParser(description="Prefetch a TT Local Hugging Face base model.")
141
+ parser.add_argument("--input", required=True)
142
+ parser.add_argument("--output", required=True)
143
+ args = parser.parse_args()
144
+
145
+ payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
146
+ base_model = str(payload["base_model"])
147
+ if base_model != CERTIFIED_BASE_MODEL:
148
+ raise ValueError(
149
+ f"The bundled trainer currently certifies only {CERTIFIED_BASE_MODEL}; got {base_model!r}"
150
+ )
151
+ cache_dir = payload.get("model_cache")
152
+ configure_hugging_face_cache(cache_dir)
153
+
154
+ # Import only after the cache environment is final. huggingface_hub reads
155
+ # these variables while importing its constants module.
156
+ from huggingface_hub import constants, snapshot_download
157
+
158
+ action = "Verifying cached" if payload.get("local_files_only") else "Prefetching"
159
+ print(f"{action} {base_model} in {constants.HF_HUB_CACHE}...", flush=True)
160
+ snapshot_path = snapshot_download(
161
+ repo_id=base_model,
162
+ revision=payload.get("revision"),
163
+ token=os.getenv("HF_TOKEN"),
164
+ allow_patterns=ALLOW_PATTERNS,
165
+ ignore_patterns=IGNORE_PATTERNS,
166
+ local_files_only=bool(payload.get("local_files_only", False)),
167
+ )
168
+ snapshot = Path(snapshot_path)
169
+ snapshot_revision = snapshot.name
170
+ if not re.fullmatch(r"[0-9a-fA-F]{40}", snapshot_revision):
171
+ raise ValueError(
172
+ "Hugging Face snapshot did not resolve to a 40-character immutable commit SHA"
173
+ )
174
+ print(f"Verifying snapshot files under {snapshot}...", flush=True)
175
+ snapshot_files, verified_blob_count = verify_snapshot(snapshot)
176
+
177
+ write_json(args.output, {
178
+ "ok": True,
179
+ "base_model": base_model,
180
+ "model_cache": str(constants.HF_HOME),
181
+ "hf_home": str(constants.HF_HOME),
182
+ "hub_cache": str(constants.HF_HUB_CACHE),
183
+ "snapshot_path": snapshot_path,
184
+ "snapshot_revision": snapshot_revision.lower(),
185
+ "file_count": len(snapshot_files),
186
+ "size_bytes": sum(path.stat().st_size for path in snapshot_files),
187
+ "verified_blob_count": verified_blob_count,
188
+ })
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()