@tuned-tensor/local 0.2.6 → 0.2.8

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +149 -0
  3. package/dist/artifacts.d.ts +92 -0
  4. package/dist/artifacts.js +591 -3
  5. package/dist/artifacts.js.map +1 -1
  6. package/dist/contracts.d.ts +5 -2
  7. package/dist/contracts.js +15 -10
  8. package/dist/contracts.js.map +1 -1
  9. package/dist/dataset.d.ts +3 -0
  10. package/dist/dataset.js +87 -5
  11. package/dist/dataset.js.map +1 -1
  12. package/dist/doctor.d.ts +13 -2
  13. package/dist/doctor.js +372 -49
  14. package/dist/doctor.js.map +1 -1
  15. package/dist/evaluation.d.ts +15 -0
  16. package/dist/evaluation.js +120 -16
  17. package/dist/evaluation.js.map +1 -1
  18. package/dist/huggingface-cache.d.ts +26 -0
  19. package/dist/huggingface-cache.js +68 -0
  20. package/dist/huggingface-cache.js.map +1 -0
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.js +766 -73
  23. package/dist/index.js.map +1 -1
  24. package/dist/local-project.d.ts +11 -0
  25. package/dist/local-project.js +96 -3
  26. package/dist/local-project.js.map +1 -1
  27. package/dist/model-registry.d.ts +21 -0
  28. package/dist/model-registry.js +198 -0
  29. package/dist/model-registry.js.map +1 -1
  30. package/dist/model-server.d.ts +32 -0
  31. package/dist/model-server.js +158 -0
  32. package/dist/model-server.js.map +1 -0
  33. package/dist/orchestrator.d.ts +3 -0
  34. package/dist/orchestrator.js +1001 -142
  35. package/dist/orchestrator.js.map +1 -1
  36. package/dist/prefetch.d.ts +43 -0
  37. package/dist/prefetch.js +192 -0
  38. package/dist/prefetch.js.map +1 -0
  39. package/dist/process-runner.d.ts +7 -0
  40. package/dist/process-runner.js +171 -16
  41. package/dist/process-runner.js.map +1 -1
  42. package/dist/process-training.d.ts +5 -1
  43. package/dist/process-training.js +32 -16
  44. package/dist/process-training.js.map +1 -1
  45. package/dist/run-reporter.d.ts +2 -0
  46. package/dist/run-reporter.js +10 -0
  47. package/dist/run-reporter.js.map +1 -1
  48. package/dist/store.d.ts +16 -2
  49. package/dist/store.js +189 -24
  50. package/dist/store.js.map +1 -1
  51. package/docs/architecture.md +32 -0
  52. package/docs/local-workflow-remediation-2026-07-13.md +130 -0
  53. package/docs/local-workflow-ux-review-2026-07-13.md +458 -0
  54. package/docs/spark.md +10 -0
  55. package/package.json +3 -1
  56. package/training/local-runner/pyproject.toml +1 -0
  57. package/training/local-runner/src/evaluate.py +80 -20
  58. package/training/local-runner/src/prefetch.py +178 -0
  59. package/training/local-runner/src/serve.py +329 -0
  60. package/training/local-runner/src/train.py +47 -22
  61. package/training/local-runner/src/train_dpo.py +41 -25
  62. package/training/local-runner/uv.lock +2401 -0
@@ -0,0 +1,178 @@
1
+ import argparse
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ ALLOW_PATTERNS = [
9
+ "*.json",
10
+ "*.model",
11
+ "*.py",
12
+ "*.safetensors",
13
+ "*.safetensors.index.json",
14
+ "*.tiktoken",
15
+ "*.txt",
16
+ "chat_template.jinja",
17
+ "merges.txt",
18
+ "pytorch_model*.bin",
19
+ "sentencepiece.bpe.model",
20
+ "spiece.model",
21
+ "tokenizer.*",
22
+ "vocab.*",
23
+ ]
24
+
25
+ IGNORE_PATTERNS = [
26
+ "*.ckpt",
27
+ "*.gguf",
28
+ "*.h5",
29
+ "*.msgpack",
30
+ "*.onnx",
31
+ "*.ot",
32
+ "optimizer.pt",
33
+ "rng_state*.pth",
34
+ "scheduler.pt",
35
+ "trainer_state.json",
36
+ "training_args.bin",
37
+ ]
38
+
39
+
40
+ def write_json(path: str, value: dict[str, Any]) -> None:
41
+ destination = Path(path)
42
+ destination.parent.mkdir(parents=True, exist_ok=True)
43
+ destination.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
44
+
45
+
46
+ def configure_hugging_face_cache(cache_home: str | None) -> None:
47
+ """Treat model_cache as HF_HOME and keep legacy overrides consistent."""
48
+ if not cache_home:
49
+ return
50
+ # Keep the lexical absolute path supplied by the Node runner. Path.resolve
51
+ # canonicalizes macOS /var to /private/var, making metadata disagree with
52
+ # training even though both locations identify the same directory.
53
+ home = Path(cache_home).expanduser().absolute()
54
+ hub = home / "hub"
55
+ os.environ.update({
56
+ "HF_HOME": str(home),
57
+ "HF_HUB_CACHE": str(hub),
58
+ "HUGGINGFACE_HUB_CACHE": str(hub),
59
+ })
60
+ for deprecated in (
61
+ "TRANSFORMERS_CACHE",
62
+ "PYTORCH_TRANSFORMERS_CACHE",
63
+ "PYTORCH_PRETRAINED_BERT_CACHE",
64
+ ):
65
+ os.environ.pop(deprecated, None)
66
+
67
+
68
+ def hash_file(path: Path, algorithm: str, prefix: bytes = b"") -> str:
69
+ digest = hashlib.new(algorithm)
70
+ digest.update(prefix)
71
+ with path.open("rb") as source:
72
+ for chunk in iter(lambda: source.read(8 * 1024 * 1024), b""):
73
+ digest.update(chunk)
74
+ return digest.hexdigest()
75
+
76
+
77
+ def verify_snapshot(snapshot: Path) -> tuple[list[Path], int]:
78
+ config = snapshot / "config.json"
79
+ if not config.is_file() or config.stat().st_size == 0:
80
+ raise ValueError(f"Cached snapshot is missing a non-empty config.json: {snapshot}")
81
+ try:
82
+ json.loads(config.read_text(encoding="utf-8"))
83
+ except (OSError, json.JSONDecodeError) as exc:
84
+ raise ValueError(f"Cached snapshot has an invalid config.json: {snapshot}") from exc
85
+
86
+ weight_files = sorted(snapshot.glob("*.safetensors")) + sorted(snapshot.glob("pytorch_model*.bin"))
87
+ index_files = sorted(snapshot.glob("*.safetensors.index.json")) + sorted(snapshot.glob("pytorch_model*.bin.index.json"))
88
+ for index in index_files:
89
+ try:
90
+ weight_map = json.loads(index.read_text(encoding="utf-8")).get("weight_map", {})
91
+ except (OSError, json.JSONDecodeError) as exc:
92
+ raise ValueError(f"Cached snapshot has an invalid weight index: {index}") from exc
93
+ if not isinstance(weight_map, dict) or not weight_map:
94
+ raise ValueError(f"Cached snapshot has an empty weight index: {index}")
95
+ for name in set(weight_map.values()):
96
+ if not isinstance(name, str) or not (snapshot / name).is_file():
97
+ raise ValueError(f"Cached snapshot is missing an indexed weight shard: {name}")
98
+ if not weight_files:
99
+ raise ValueError(f"Cached snapshot contains no Transformers model weights: {snapshot}")
100
+ for weight in weight_files:
101
+ if weight.stat().st_size == 0:
102
+ raise ValueError(f"Cached snapshot contains an empty model weight: {weight}")
103
+
104
+ tokenizer_config = snapshot / "tokenizer_config.json"
105
+ vocabulary = [
106
+ snapshot / "tokenizer.json",
107
+ snapshot / "tokenizer.model",
108
+ snapshot / "sentencepiece.bpe.model",
109
+ snapshot / "spiece.model",
110
+ snapshot / "vocab.json",
111
+ snapshot / "tokenizer.tiktoken",
112
+ ]
113
+ if not tokenizer_config.is_file() or not any(path.is_file() and path.stat().st_size > 0 for path in vocabulary):
114
+ raise ValueError(f"Cached snapshot is missing tokenizer metadata or vocabulary files: {snapshot}")
115
+
116
+ files = [path for path in snapshot.rglob("*") if path.is_file()]
117
+ verified_blobs = 0
118
+ for path in files:
119
+ blob = path.resolve()
120
+ expected = blob.name.lower()
121
+ if len(expected) == 64 and all(character in "0123456789abcdef" for character in expected):
122
+ actual = hash_file(blob, "sha256")
123
+ elif len(expected) == 40 and all(character in "0123456789abcdef" for character in expected):
124
+ actual = hash_file(blob, "sha1", f"blob {blob.stat().st_size}\0".encode())
125
+ else:
126
+ continue
127
+ if actual != expected:
128
+ raise ValueError(f"Cached Hugging Face blob checksum mismatch: {path}")
129
+ verified_blobs += 1
130
+ return files, verified_blobs
131
+
132
+
133
+ def main() -> None:
134
+ parser = argparse.ArgumentParser(description="Prefetch a TT Local Hugging Face base model.")
135
+ parser.add_argument("--input", required=True)
136
+ parser.add_argument("--output", required=True)
137
+ args = parser.parse_args()
138
+
139
+ payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
140
+ base_model = str(payload["base_model"])
141
+ cache_dir = payload.get("model_cache")
142
+ configure_hugging_face_cache(cache_dir)
143
+
144
+ # Import only after the cache environment is final. huggingface_hub reads
145
+ # these variables while importing its constants module.
146
+ from huggingface_hub import constants, snapshot_download
147
+
148
+ action = "Verifying cached" if payload.get("local_files_only") else "Prefetching"
149
+ print(f"{action} {base_model} in {constants.HF_HUB_CACHE}...", flush=True)
150
+ snapshot_path = snapshot_download(
151
+ repo_id=base_model,
152
+ revision=payload.get("revision"),
153
+ token=os.getenv("HF_TOKEN"),
154
+ allow_patterns=ALLOW_PATTERNS,
155
+ ignore_patterns=IGNORE_PATTERNS,
156
+ local_files_only=bool(payload.get("local_files_only", False)),
157
+ )
158
+ snapshot = Path(snapshot_path)
159
+ print(f"Verifying snapshot files under {snapshot}...", flush=True)
160
+ snapshot_files, verified_blob_count = verify_snapshot(snapshot)
161
+
162
+ write_json(args.output, {
163
+ "ok": True,
164
+ "base_model": base_model,
165
+ "loader": payload.get("loader"),
166
+ "model_cache": str(constants.HF_HOME),
167
+ "hf_home": str(constants.HF_HOME),
168
+ "hub_cache": str(constants.HF_HUB_CACHE),
169
+ "snapshot_path": snapshot_path,
170
+ "snapshot_revision": Path(snapshot_path).name,
171
+ "file_count": len(snapshot_files),
172
+ "size_bytes": sum(path.stat().st_size for path in snapshot_files),
173
+ "verified_blob_count": verified_blob_count,
174
+ })
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()
@@ -0,0 +1,329 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import base64
5
+ import hmac
6
+ import math
7
+ import os
8
+ import sys
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from pathlib import Path
14
+ from tempfile import TemporaryDirectory
15
+ from typing import Any
16
+
17
+ # HF_HOME is supplied by the Node launcher before this module starts. Importing
18
+ # the model stack only after reading process configuration keeps every local
19
+ # workflow on the same Hugging Face cache contract.
20
+ from evaluate import ( # noqa: E402
21
+ configure_hugging_face_cache,
22
+ generate_multimodal_one,
23
+ import_runtime_dependencies,
24
+ load_multimodal_model,
25
+ load_text_model,
26
+ resolve_adapter_path,
27
+ sampling_kwargs,
28
+ )
29
+
30
+
31
+ MODEL_ARTIFACT = os.environ["TT_MODEL_ARTIFACT"]
32
+ BASE_MODEL = os.environ["TT_BASE_MODEL"]
33
+ BASE_MODEL_REVISION = os.environ.get("TT_BASE_MODEL_REVISION")
34
+ MODEL_NAME = os.environ.get("TT_MODEL_NAME", "tuned-tensor-local")
35
+ MODEL_LOADER = os.environ.get("TT_MODEL_LOADER", "causal_lm")
36
+ SYSTEM_PROMPT = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
37
+ HOST = os.environ.get("TT_HOST", "127.0.0.1")
38
+ PORT = int(os.environ.get("TT_PORT", "8000"))
39
+ DEVICE_REQUEST = os.environ.get("TT_DEVICE", "auto")
40
+ TRUST_REMOTE_CODE = os.environ.get("TT_TRUST_REMOTE_CODE", "true").lower() in {"1", "true", "yes"}
41
+ DEFAULT_MAX_TOKENS = int(os.environ.get("TT_MAX_TOKENS", "512"))
42
+ DEFAULT_TEMPERATURE = float(os.environ.get("TT_TEMPERATURE", "0"))
43
+ DEFAULT_TOP_P = float(os.environ.get("TT_TOP_P", "1"))
44
+ CHAT_TEMPLATE_KWARGS = json.loads(os.environ.get("TT_CHAT_TEMPLATE_KWARGS", "{}"))
45
+ MAX_REQUEST_BYTES = 2 * 1024 * 1024
46
+ MAX_PROMPT_CHARS = 100_000
47
+ MAX_PROMPT_TOKENS = 16_384
48
+ MAX_MESSAGES = 128
49
+ MAX_IMAGE_BYTES = 5 * 1024 * 1024
50
+ API_KEY = os.environ.get("TT_API_KEY", "")
51
+ MAX_CONCURRENT_REQUESTS = int(os.environ.get("TT_MAX_CONCURRENT_REQUESTS", "1"))
52
+
53
+
54
+ configure_hugging_face_cache(os.environ.get("HF_HOME"))
55
+ import_runtime_dependencies()
56
+ TEMP_DIR = TemporaryDirectory(prefix="tt-local-serve-")
57
+ ADAPTER_PATH = resolve_adapter_path(MODEL_ARTIFACT, Path(TEMP_DIR.name))
58
+ MODEL_PAYLOAD = {
59
+ "base_model": BASE_MODEL,
60
+ "base_model_revision": BASE_MODEL_REVISION,
61
+ "device": DEVICE_REQUEST,
62
+ "trust_remote_code": TRUST_REMOTE_CODE,
63
+ "model_loader": MODEL_LOADER,
64
+ }
65
+
66
+ if MODEL_LOADER == "image_text_to_text":
67
+ MODEL, PROCESSOR, DEVICE = load_multimodal_model(MODEL_PAYLOAD, ADAPTER_PATH)
68
+ TOKENIZER = getattr(PROCESSOR, "tokenizer", PROCESSOR)
69
+ else:
70
+ MODEL, TOKENIZER, DEVICE = load_text_model(MODEL_PAYLOAD, ADAPTER_PATH)
71
+ PROCESSOR = None
72
+
73
+ GENERATION_LOCK = threading.Lock()
74
+ REQUEST_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
75
+
76
+
77
+ def normalize_messages(raw_messages: Any) -> list[dict[str, Any]]:
78
+ if not isinstance(raw_messages, list) or not raw_messages:
79
+ raise ValueError("Request must include a non-empty messages array.")
80
+ if len(raw_messages) > MAX_MESSAGES:
81
+ raise ValueError(f"Request exceeds the {MAX_MESSAGES}-message limit.")
82
+ messages: list[dict[str, Any]] = []
83
+ for raw in raw_messages:
84
+ if not isinstance(raw, dict) or raw.get("role") not in {"system", "user", "assistant", "tool"}:
85
+ raise ValueError("Each message must have a supported role and content.")
86
+ messages.append({"role": raw["role"], "content": raw.get("content", "")})
87
+ # A spec passed by the server owner is invariant behavior. Client-supplied
88
+ # system messages may add context but cannot replace it.
89
+ if SYSTEM_PROMPT:
90
+ messages.insert(0, {"role": "system", "content": SYSTEM_PROMPT})
91
+ if sum(len(text_content(message.get("content"))) for message in messages) > MAX_PROMPT_CHARS:
92
+ raise ValueError(f"Prompt exceeds the {MAX_PROMPT_CHARS}-character limit.")
93
+ return messages
94
+
95
+
96
+ def text_content(content: Any) -> str:
97
+ if isinstance(content, str):
98
+ return content
99
+ if not isinstance(content, list):
100
+ return str(content) if content is not None else ""
101
+ parts: list[str] = []
102
+ for part in content:
103
+ if isinstance(part, dict) and part.get("type") == "text":
104
+ parts.append(str(part.get("text", "")))
105
+ elif isinstance(part, dict) and part.get("type") in {"image", "image_url"}:
106
+ continue
107
+ else:
108
+ parts.append(str(part))
109
+ return "\n".join(part for part in parts if part)
110
+
111
+
112
+ def image_assets(messages: list[dict[str, Any]]) -> list[dict[str, str]]:
113
+ assets: list[dict[str, str]] = []
114
+ for message in messages:
115
+ content = message.get("content")
116
+ if not isinstance(content, list):
117
+ continue
118
+ for part in content:
119
+ if not isinstance(part, dict) or part.get("type") not in {"image", "image_url"}:
120
+ continue
121
+ image = part.get("image_url")
122
+ if isinstance(image, dict):
123
+ image = image.get("url")
124
+ image = image or part.get("image") or part.get("data_uri") or part.get("uri") or part.get("path")
125
+ if isinstance(image, str) and image:
126
+ if not image.startswith("data:image/") or "," not in image:
127
+ raise ValueError("Serving accepts image content only as data:image/... URIs.")
128
+ encoded = image.split(",", 1)[1]
129
+ try:
130
+ decoded_size = len(base64.b64decode(encoded, validate=True))
131
+ except Exception as exc:
132
+ raise ValueError("Image data URI contains invalid base64.") from exc
133
+ if decoded_size > MAX_IMAGE_BYTES:
134
+ raise ValueError(f"Image exceeds the {MAX_IMAGE_BYTES}-byte decoded limit.")
135
+ assets.append({"type": "image", "image": image})
136
+ return assets
137
+
138
+
139
+ def generate_text(messages: list[dict[str, Any]], generation: dict[str, Any]) -> tuple[str, int, int]:
140
+ normalized = [{"role": message["role"], "content": text_content(message.get("content"))} for message in messages]
141
+ try:
142
+ prompt = TOKENIZER.apply_chat_template(
143
+ normalized,
144
+ tokenize=False,
145
+ add_generation_prompt=True,
146
+ **CHAT_TEMPLATE_KWARGS,
147
+ )
148
+ except Exception:
149
+ prompt = "\n".join(f"{message['role']}: {message['content']}" for message in normalized) + "\nassistant:"
150
+ inputs = TOKENIZER(prompt, return_tensors="pt")
151
+ if int(inputs["input_ids"].shape[-1]) > MAX_PROMPT_TOKENS:
152
+ raise ValueError(f"Prompt exceeds the {MAX_PROMPT_TOKENS}-token limit.")
153
+ target_device = next(MODEL.parameters()).device
154
+ inputs = {key: value.to(target_device) for key, value in inputs.items()}
155
+ with GENERATION_LOCK:
156
+ import torch
157
+
158
+ with torch.no_grad():
159
+ generated = MODEL.generate(
160
+ **inputs,
161
+ max_new_tokens=int(generation["max_new_tokens"]),
162
+ pad_token_id=TOKENIZER.eos_token_id,
163
+ **sampling_kwargs(generation),
164
+ )
165
+ prompt_tokens = int(inputs["input_ids"].shape[-1])
166
+ completion_tokens = generated[0][prompt_tokens:]
167
+ content = TOKENIZER.decode(completion_tokens, skip_special_tokens=True).strip()
168
+ return content, prompt_tokens, int(completion_tokens.shape[-1])
169
+
170
+
171
+ def generate_multimodal(messages: list[dict[str, Any]], generation: dict[str, Any]) -> tuple[str, int, int]:
172
+ system_parts = [text_content(message.get("content")) for message in messages if message["role"] == "system"]
173
+ conversation = "\n".join(
174
+ f"{message['role']}: {text_content(message.get('content'))}"
175
+ for message in messages
176
+ if message["role"] != "system"
177
+ )
178
+ example = {
179
+ "input": conversation,
180
+ "output": "",
181
+ "input_assets": image_assets(messages),
182
+ }
183
+ with GENERATION_LOCK:
184
+ result = generate_multimodal_one(
185
+ MODEL,
186
+ PROCESSOR,
187
+ "\n\n".join(part for part in system_parts if part),
188
+ example,
189
+ generation,
190
+ Path.cwd(),
191
+ CHAT_TEMPLATE_KWARGS,
192
+ )
193
+ # The evaluator does not currently expose token counts for multimodal
194
+ # requests. Returning zero is preferable to inventing usage data.
195
+ return str(result["actual"]), 0, 0
196
+
197
+
198
+ def bounded_number(value: Any, default: float, minimum: float, maximum: float) -> float:
199
+ number = float(default if value is None else value)
200
+ if not math.isfinite(number) or number < minimum or number > maximum:
201
+ raise ValueError(f"Generation value must be between {minimum} and {maximum}.")
202
+ return number
203
+
204
+
205
+ def bounded_integer(value: Any, default: int, minimum: int, maximum: int) -> int:
206
+ number = bounded_number(value, default, minimum, maximum)
207
+ if not number.is_integer():
208
+ raise ValueError(f"Generation value must be an integer between {minimum} and {maximum}.")
209
+ return int(number)
210
+
211
+
212
+ class Handler(BaseHTTPRequestHandler):
213
+ server_version = "TunedTensorLocalServer/0.1"
214
+
215
+ def setup(self) -> None:
216
+ super().setup()
217
+ self.connection.settimeout(30)
218
+
219
+ def authorized(self) -> bool:
220
+ if not API_KEY:
221
+ return True
222
+ supplied = self.headers.get("authorization", "")
223
+ expected = "Bearer " + API_KEY
224
+ return hmac.compare_digest(supplied, expected)
225
+
226
+ def send_json(self, status: int, payload: Any) -> None:
227
+ body = json.dumps(payload).encode("utf-8")
228
+ self.send_response(status)
229
+ self.send_header("Content-Type", "application/json")
230
+ self.send_header("Content-Length", str(len(body)))
231
+ self.end_headers()
232
+ self.wfile.write(body)
233
+
234
+ def do_GET(self) -> None: # noqa: N802
235
+ if not self.authorized():
236
+ self.send_json(401, {"error": {"message": "Unauthorized"}})
237
+ return
238
+ if self.path == "/health":
239
+ self.send_json(200, {"status": "ok", "model": MODEL_NAME, "device": DEVICE})
240
+ return
241
+ if self.path == "/v1/models":
242
+ self.send_json(200, {
243
+ "object": "list",
244
+ "data": [{"id": MODEL_NAME, "object": "model", "owned_by": "tuned-tensor-local"}],
245
+ })
246
+ return
247
+ self.send_json(404, {"error": {"message": "Not found"}})
248
+
249
+ def do_POST(self) -> None: # noqa: N802
250
+ if not self.authorized():
251
+ self.send_json(401, {"error": {"message": "Unauthorized"}})
252
+ return
253
+ if self.path not in {"/v1/chat/completions", "/chat/completions"}:
254
+ self.send_json(404, {"error": {"message": "Not found"}})
255
+ return
256
+ try:
257
+ length = int(self.headers.get("content-length", "0"))
258
+ if length <= 0 or length > MAX_REQUEST_BYTES:
259
+ raise ValueError("Request body is empty or too large.")
260
+ body = json.loads(self.rfile.read(length))
261
+ if not isinstance(body, dict):
262
+ raise ValueError("Request body must be a JSON object.")
263
+ if body.get("stream"):
264
+ self.send_json(400, {"error": {"message": "Streaming is not supported yet."}})
265
+ return
266
+ messages = normalize_messages(body.get("messages"))
267
+ generation = {
268
+ "max_new_tokens": bounded_integer(body.get("max_tokens"), DEFAULT_MAX_TOKENS, 1, 8192),
269
+ "temperature": bounded_number(body.get("temperature"), DEFAULT_TEMPERATURE, 0, 5),
270
+ "top_p": bounded_number(body.get("top_p"), DEFAULT_TOP_P, 0, 1),
271
+ }
272
+ if not REQUEST_SLOTS.acquire(blocking=False):
273
+ self.send_json(429, {"error": {"message": "The local model is busy; retry shortly."}})
274
+ return
275
+ started = time.perf_counter()
276
+ try:
277
+ if MODEL_LOADER == "image_text_to_text":
278
+ content, prompt_tokens, completion_tokens = generate_multimodal(messages, generation)
279
+ else:
280
+ if image_assets(messages):
281
+ raise ValueError("This text model does not accept image content parts.")
282
+ content, prompt_tokens, completion_tokens = generate_text(messages, generation)
283
+ finally:
284
+ REQUEST_SLOTS.release()
285
+ latency_ms = round((time.perf_counter() - started) * 1000)
286
+ self.send_json(200, {
287
+ "id": "chatcmpl-" + uuid.uuid4().hex,
288
+ "object": "chat.completion",
289
+ "created": int(time.time()),
290
+ "model": MODEL_NAME,
291
+ "choices": [{
292
+ "index": 0,
293
+ "message": {"role": "assistant", "content": content},
294
+ "finish_reason": "stop",
295
+ }],
296
+ "usage": {
297
+ "prompt_tokens": prompt_tokens,
298
+ "completion_tokens": completion_tokens,
299
+ "total_tokens": prompt_tokens + completion_tokens,
300
+ },
301
+ "tt_local": {"latency_ms": latency_ms, "device": DEVICE},
302
+ })
303
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
304
+ self.send_json(400, {"error": {"message": str(exc)}})
305
+ except Exception as exc:
306
+ print(f"Model server error: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
307
+ self.send_json(500, {"error": {"message": "Internal model server error."}})
308
+
309
+ def log_message(self, fmt: str, *args: Any) -> None:
310
+ print("%s - %s" % (self.address_string(), fmt % args), flush=True)
311
+
312
+
313
+ def main() -> None:
314
+ print(f"Serving {MODEL_NAME} on http://{HOST}:{PORT}", flush=True)
315
+ print(f"OpenAI-compatible endpoint: http://{HOST}:{PORT}/v1/chat/completions", flush=True)
316
+ print(f"Device: {DEVICE}", flush=True)
317
+ server = ThreadingHTTPServer((HOST, PORT), Handler)
318
+ server.daemon_threads = True
319
+ server.request_queue_size = max(2, MAX_CONCURRENT_REQUESTS * 2)
320
+ try:
321
+ server.serve_forever()
322
+ except KeyboardInterrupt:
323
+ print("Model server stopped.", flush=True)
324
+ finally:
325
+ server.server_close()
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
@@ -36,6 +36,8 @@ HYPERPARAMETERS_PATH = Path(
36
36
  MODEL_DIR = Path(os.environ.get("SM_MODEL_DIR", "/opt/ml/model"))
37
37
  OUTPUT_DIR = Path(os.environ.get("SM_OUTPUT_DIR", "/opt/ml/output"))
38
38
  ROW_SOURCE_DIR_KEY = "_tt_source_dir"
39
+ MAX_ARCHIVE_MEMBERS = 20_000
40
+ MAX_ARCHIVE_EXPANDED_BYTES = 20 * 1024 * 1024 * 1024
39
41
 
40
42
 
41
43
  def load_hyperparameters() -> dict[str, str]:
@@ -70,6 +72,11 @@ def hp_bool(name: str, default: bool) -> bool:
70
72
  return (hp(name, str(default)) or "").lower() in {"1", "true", "yes", "y"}
71
73
 
72
74
 
75
+ def model_revision_kwargs(model_source: str) -> dict[str, str]:
76
+ revision = hp("base_model_revision")
77
+ return {"revision": revision} if revision and not Path(model_source).exists() else {}
78
+
79
+
73
80
  def load_rows() -> list[dict[str, Any]]:
74
81
  rows: list[dict[str, Any]] = []
75
82
  for path in sorted(TRAINING_DIR.rglob("*.jsonl")):
@@ -86,15 +93,36 @@ def load_rows() -> list[dict[str, Any]]:
86
93
  return rows
87
94
 
88
95
 
89
- def resolve_model_source() -> str:
96
+ def safe_extract_archive(path: Path, destination: Path) -> None:
97
+ destination.mkdir(parents=True, exist_ok=True)
98
+ destination_root = destination.resolve()
99
+ with tarfile.open(path, "r:gz") as tar:
100
+ members = tar.getmembers()
101
+ if len(members) > MAX_ARCHIVE_MEMBERS:
102
+ raise ValueError(f"Model archive exceeds {MAX_ARCHIVE_MEMBERS} members")
103
+ expanded_bytes = sum(max(0, member.size) for member in members if member.isfile())
104
+ if expanded_bytes > MAX_ARCHIVE_EXPANDED_BYTES:
105
+ raise ValueError("Model archive exceeds the 20 GiB expanded-size limit")
106
+ for member in members:
107
+ destination_path = (destination / member.name).resolve()
108
+ try:
109
+ destination_path.relative_to(destination_root)
110
+ except ValueError:
111
+ raise ValueError(f"Unsafe archive member: {member.name}")
112
+ if member.issym() or member.islnk() or member.isdev():
113
+ raise ValueError(f"Unsafe archive member type: {member.name}")
114
+ tar.extractall(destination)
115
+
116
+
117
+ def resolve_model_source(tmp: Path) -> str:
90
118
  archive = BASE_MODEL_DIR / "model.tar.gz"
91
119
  if archive.is_file():
92
- extracted = Path("/tmp/base-model")
93
- extracted.mkdir(parents=True, exist_ok=True)
94
- with tarfile.open(archive, "r:gz") as tar:
95
- tar.extractall(extracted)
120
+ extracted = tmp / "base-model"
121
+ safe_extract_archive(archive, extracted)
96
122
  candidates = [path for path in extracted.iterdir() if path.is_dir()]
97
123
  return str(candidates[0] if len(candidates) == 1 else extracted)
124
+ if BASE_MODEL_DIR.is_dir() and any(BASE_MODEL_DIR.iterdir()):
125
+ return str(BASE_MODEL_DIR)
98
126
  return hp("base_model") or "Qwen/Qwen3.5-2B"
99
127
 
100
128
 
@@ -113,16 +141,7 @@ def resolve_adapter_path(value: str | None, tmp: Path) -> str | None:
113
141
  path = Path(path_value)
114
142
  if path.is_file() and path.name.endswith(".tar.gz"):
115
143
  extracted = tmp / "parent-adapter"
116
- extracted.mkdir(parents=True, exist_ok=True)
117
- with tarfile.open(path, "r:gz") as tar:
118
- extracted_root = extracted.resolve()
119
- for member in tar.getmembers():
120
- destination = (extracted / member.name).resolve()
121
- try:
122
- destination.relative_to(extracted_root)
123
- except ValueError:
124
- raise ValueError(f"Unsafe archive member: {member.name}")
125
- tar.extractall(extracted)
144
+ safe_extract_archive(path, extracted)
126
145
  candidates = [candidate for candidate in extracted.rglob("adapter_config.json")]
127
146
  if candidates:
128
147
  return str(candidates[0].parent)
@@ -271,6 +290,7 @@ def create_text_model_and_tokenizer(model_source: str):
271
290
  token = os.getenv("HF_TOKEN")
272
291
  tokenizer = AutoTokenizer.from_pretrained(
273
292
  model_source,
293
+ **model_revision_kwargs(model_source),
274
294
  trust_remote_code=trust_remote_code,
275
295
  token=token,
276
296
  )
@@ -280,6 +300,7 @@ def create_text_model_and_tokenizer(model_source: str):
280
300
  dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
281
301
  model = AutoModelForCausalLM.from_pretrained(
282
302
  model_source,
303
+ **model_revision_kwargs(model_source),
283
304
  trust_remote_code=trust_remote_code,
284
305
  token=token,
285
306
  torch_dtype=dtype if torch.cuda.is_available() else None,
@@ -294,6 +315,7 @@ def create_multimodal_model_and_processor(model_source: str):
294
315
  token = os.getenv("HF_TOKEN")
295
316
  processor = AutoProcessor.from_pretrained(
296
317
  model_source,
318
+ **model_revision_kwargs(model_source),
297
319
  trust_remote_code=trust_remote_code,
298
320
  token=token,
299
321
  )
@@ -303,6 +325,7 @@ def create_multimodal_model_and_processor(model_source: str):
303
325
  dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
304
326
  model = AutoModelForImageTextToText.from_pretrained(
305
327
  model_source,
328
+ **model_revision_kwargs(model_source),
306
329
  trust_remote_code=trust_remote_code,
307
330
  token=token,
308
331
  torch_dtype=dtype if torch.cuda.is_available() else None,
@@ -438,17 +461,19 @@ def main() -> None:
438
461
  OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
439
462
 
440
463
  rows = load_rows()
441
- model_source = resolve_model_source()
442
- model_loader = hp("model_loader", "causal_lm")
443
- if model_loader == "image_text_to_text":
444
- metrics = run_multimodal_training(rows, model_source)
445
- else:
446
- metrics = run_text_training(rows, model_source)
464
+ with TemporaryDirectory(prefix="tt-local-base-model-") as base_tmp:
465
+ model_source = resolve_model_source(Path(base_tmp))
466
+ model_loader = hp("model_loader", "causal_lm")
467
+ if model_loader == "image_text_to_text":
468
+ metrics = run_multimodal_training(rows, model_source)
469
+ else:
470
+ metrics = run_text_training(rows, model_source)
447
471
 
448
472
  archive_path = create_model_archive()
449
473
  output_metrics = {
450
474
  "training_rows": len(rows),
451
- "model_source": model_source,
475
+ "model_source": str(BASE_MODEL_DIR) if BASE_MODEL_DIR.is_dir() and any(BASE_MODEL_DIR.iterdir()) else hp("base_model"),
476
+ "base_model_revision": hp("base_model_revision"),
452
477
  "model_loader": model_loader,
453
478
  "parent_model_artifact": hp("parent_model_artifact"),
454
479
  "train_runtime": round(time.time() - started, 3),