@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.
- package/CHANGELOG.md +60 -0
- package/README.md +149 -0
- package/dist/artifacts.d.ts +92 -0
- package/dist/artifacts.js +591 -3
- package/dist/artifacts.js.map +1 -1
- package/dist/contracts.d.ts +5 -2
- package/dist/contracts.js +15 -10
- package/dist/contracts.js.map +1 -1
- package/dist/dataset.d.ts +3 -0
- package/dist/dataset.js +87 -5
- package/dist/dataset.js.map +1 -1
- package/dist/doctor.d.ts +13 -2
- package/dist/doctor.js +372 -49
- package/dist/doctor.js.map +1 -1
- package/dist/evaluation.d.ts +15 -0
- package/dist/evaluation.js +120 -16
- package/dist/evaluation.js.map +1 -1
- package/dist/huggingface-cache.d.ts +26 -0
- package/dist/huggingface-cache.js +68 -0
- package/dist/huggingface-cache.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +766 -73
- package/dist/index.js.map +1 -1
- package/dist/local-project.d.ts +11 -0
- package/dist/local-project.js +96 -3
- package/dist/local-project.js.map +1 -1
- package/dist/model-registry.d.ts +21 -0
- package/dist/model-registry.js +198 -0
- package/dist/model-registry.js.map +1 -1
- package/dist/model-server.d.ts +32 -0
- package/dist/model-server.js +158 -0
- package/dist/model-server.js.map +1 -0
- package/dist/orchestrator.d.ts +3 -0
- package/dist/orchestrator.js +1001 -142
- package/dist/orchestrator.js.map +1 -1
- package/dist/prefetch.d.ts +43 -0
- package/dist/prefetch.js +192 -0
- package/dist/prefetch.js.map +1 -0
- package/dist/process-runner.d.ts +7 -0
- package/dist/process-runner.js +171 -16
- package/dist/process-runner.js.map +1 -1
- package/dist/process-training.d.ts +5 -1
- package/dist/process-training.js +32 -16
- package/dist/process-training.js.map +1 -1
- package/dist/run-reporter.d.ts +2 -0
- package/dist/run-reporter.js +10 -0
- package/dist/run-reporter.js.map +1 -1
- package/dist/store.d.ts +16 -2
- package/dist/store.js +189 -24
- package/dist/store.js.map +1 -1
- package/docs/architecture.md +32 -0
- package/docs/local-workflow-remediation-2026-07-13.md +130 -0
- package/docs/local-workflow-ux-review-2026-07-13.md +458 -0
- package/docs/spark.md +10 -0
- package/package.json +3 -1
- package/training/local-runner/pyproject.toml +1 -0
- package/training/local-runner/src/evaluate.py +80 -20
- package/training/local-runner/src/prefetch.py +178 -0
- package/training/local-runner/src/serve.py +329 -0
- package/training/local-runner/src/train.py +47 -22
- package/training/local-runner/src/train_dpo.py +41 -25
- 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
|
|
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 =
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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":
|
|
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),
|