@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,272 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import json
5
+ import math
6
+ import os
7
+ import sys
8
+ import threading
9
+ import time
10
+ import uuid
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from pathlib import Path
13
+ from tempfile import TemporaryDirectory
14
+ from typing import Any
15
+
16
+ from evaluate import (
17
+ configure_hugging_face_cache,
18
+ import_runtime_dependencies,
19
+ load_text_model,
20
+ resolve_adapter_path,
21
+ sampling_kwargs,
22
+ )
23
+
24
+
25
+ MODEL_ARTIFACT = os.environ.get("TT_MODEL_ARTIFACT")
26
+ BASE_MODEL = os.environ["TT_BASE_MODEL"]
27
+ BASE_MODEL_REVISION = os.environ.get("TT_BASE_MODEL_REVISION")
28
+ MODEL_NAME = os.environ.get("TT_MODEL_NAME", "tuned-tensor-local")
29
+ MODEL_LOADER = os.environ.get("TT_MODEL_LOADER", "causal_lm")
30
+ SYSTEM_PROMPT = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
31
+ HOST = os.environ.get("TT_HOST", "127.0.0.1")
32
+ PORT = int(os.environ.get("TT_PORT", "8000"))
33
+ DEVICE_REQUEST = os.environ.get("TT_DEVICE", "cuda")
34
+ DEFAULT_MAX_TOKENS = int(os.environ.get("TT_MAX_TOKENS", "512"))
35
+ DEFAULT_TEMPERATURE = float(os.environ.get("TT_TEMPERATURE", "0"))
36
+ DEFAULT_TOP_P = float(os.environ.get("TT_TOP_P", "1"))
37
+ MAX_REQUEST_BYTES = 2 * 1024 * 1024
38
+ MAX_PROMPT_CHARS = 100_000
39
+ MAX_PROMPT_TOKENS = 16_384
40
+ MAX_MESSAGES = 128
41
+ API_KEY = os.environ.get("TT_API_KEY", "")
42
+ MAX_CONCURRENT_REQUESTS = int(os.environ.get("TT_MAX_CONCURRENT_REQUESTS", "1"))
43
+
44
+
45
+ if MODEL_LOADER != "causal_lm":
46
+ raise ValueError("The bundled model server is text-only and requires TT_MODEL_LOADER=causal_lm")
47
+ configure_hugging_face_cache(os.environ.get("HF_HOME"))
48
+ import_runtime_dependencies()
49
+ TEMP_DIR = TemporaryDirectory(prefix="tt-local-serve-")
50
+ ADAPTER_PATH = (
51
+ resolve_adapter_path(MODEL_ARTIFACT, Path(TEMP_DIR.name))
52
+ if MODEL_ARTIFACT
53
+ else None
54
+ )
55
+ MODEL_PAYLOAD = {
56
+ "base_model": BASE_MODEL,
57
+ "base_model_revision": BASE_MODEL_REVISION,
58
+ "device": DEVICE_REQUEST,
59
+ "model_loader": MODEL_LOADER,
60
+ }
61
+ MODEL, TOKENIZER, DEVICE = load_text_model(MODEL_PAYLOAD, ADAPTER_PATH)
62
+ GENERATION_LOCK = threading.Lock()
63
+ REQUEST_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
64
+
65
+
66
+ def text_content(content: Any) -> str:
67
+ if isinstance(content, str):
68
+ return content
69
+ if content is None:
70
+ return ""
71
+ if not isinstance(content, list):
72
+ raise ValueError("Message content must be text or an array of text parts.")
73
+ parts: list[str] = []
74
+ for part in content:
75
+ if not isinstance(part, dict) or part.get("type") != "text":
76
+ raise ValueError("The bundled Qwen model server accepts text content only.")
77
+ text = part.get("text")
78
+ if not isinstance(text, str):
79
+ raise ValueError("Every text content part must contain a string text field.")
80
+ parts.append(text)
81
+ return "\n".join(parts)
82
+
83
+
84
+ def normalize_messages(raw_messages: Any) -> list[dict[str, str]]:
85
+ if not isinstance(raw_messages, list) or not raw_messages:
86
+ raise ValueError("Request must include a non-empty messages array.")
87
+ if len(raw_messages) > MAX_MESSAGES:
88
+ raise ValueError(f"Request exceeds the {MAX_MESSAGES}-message limit.")
89
+
90
+ system_parts = [SYSTEM_PROMPT] if SYSTEM_PROMPT else []
91
+ conversation: list[dict[str, str]] = []
92
+ for raw in raw_messages:
93
+ if not isinstance(raw, dict) or raw.get("role") not in {"system", "user", "assistant", "tool"}:
94
+ raise ValueError("Each message must have a supported role and text content.")
95
+ role = str(raw["role"])
96
+ content = text_content(raw.get("content", ""))
97
+ if role == "system":
98
+ if content.strip():
99
+ system_parts.append(content.strip())
100
+ else:
101
+ conversation.append({"role": role, "content": content})
102
+
103
+ if not conversation:
104
+ raise ValueError("Request must contain at least one non-system message.")
105
+ messages: list[dict[str, str]] = []
106
+ if system_parts:
107
+ # Qwen permits one leading system message. Merge the invariant owner
108
+ # prompt and any client context instead of triggering an implicit
109
+ # template fallback with duplicate system turns.
110
+ messages.append({"role": "system", "content": "\n\n".join(system_parts)})
111
+ messages.extend(conversation)
112
+ if sum(len(message["content"]) for message in messages) > MAX_PROMPT_CHARS:
113
+ raise ValueError(f"Prompt exceeds the {MAX_PROMPT_CHARS}-character limit.")
114
+ return messages
115
+
116
+
117
+ def generate_text(messages: list[dict[str, str]], generation: dict[str, Any]) -> tuple[str, int, int]:
118
+ try:
119
+ prompt = TOKENIZER.apply_chat_template(
120
+ messages,
121
+ tokenize=False,
122
+ add_generation_prompt=True,
123
+ )
124
+ except Exception as exc:
125
+ raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
126
+ inputs = TOKENIZER(prompt, return_tensors="pt")
127
+ if int(inputs["input_ids"].shape[-1]) > MAX_PROMPT_TOKENS:
128
+ raise ValueError(f"Prompt exceeds the {MAX_PROMPT_TOKENS}-token limit.")
129
+ target_device = next(MODEL.parameters()).device
130
+ inputs = {key: value.to(target_device) for key, value in inputs.items()}
131
+ with GENERATION_LOCK:
132
+ import torch
133
+
134
+ with torch.inference_mode():
135
+ generated = MODEL.generate(
136
+ **inputs,
137
+ max_new_tokens=int(generation["max_new_tokens"]),
138
+ pad_token_id=TOKENIZER.eos_token_id,
139
+ **sampling_kwargs(generation),
140
+ )
141
+ prompt_tokens = int(inputs["input_ids"].shape[-1])
142
+ completion_tokens = generated[0][prompt_tokens:]
143
+ content = TOKENIZER.decode(completion_tokens, skip_special_tokens=True).strip()
144
+ return content, prompt_tokens, int(completion_tokens.shape[-1])
145
+
146
+
147
+ def bounded_number(value: Any, default: float, minimum: float, maximum: float) -> float:
148
+ number = float(default if value is None else value)
149
+ if not math.isfinite(number) or number < minimum or number > maximum:
150
+ raise ValueError(f"Generation value must be between {minimum} and {maximum}.")
151
+ return number
152
+
153
+
154
+ def bounded_integer(value: Any, default: int, minimum: int, maximum: int) -> int:
155
+ number = bounded_number(value, default, minimum, maximum)
156
+ if not number.is_integer():
157
+ raise ValueError(f"Generation value must be an integer between {minimum} and {maximum}.")
158
+ return int(number)
159
+
160
+
161
+ class Handler(BaseHTTPRequestHandler):
162
+ server_version = "TunedTensorLocalServer/0.1"
163
+
164
+ def setup(self) -> None:
165
+ super().setup()
166
+ self.connection.settimeout(30)
167
+
168
+ def authorized(self) -> bool:
169
+ if not API_KEY:
170
+ return True
171
+ supplied = self.headers.get("authorization", "")
172
+ return hmac.compare_digest(supplied, "Bearer " + API_KEY)
173
+
174
+ def send_json(self, status: int, payload: Any) -> None:
175
+ body = json.dumps(payload).encode("utf-8")
176
+ self.send_response(status)
177
+ self.send_header("Content-Type", "application/json")
178
+ self.send_header("Content-Length", str(len(body)))
179
+ self.end_headers()
180
+ self.wfile.write(body)
181
+
182
+ def do_GET(self) -> None: # noqa: N802
183
+ if not self.authorized():
184
+ self.send_json(401, {"error": {"message": "Unauthorized"}})
185
+ return
186
+ if self.path == "/health":
187
+ self.send_json(200, {"status": "ok", "model": MODEL_NAME, "device": DEVICE})
188
+ return
189
+ if self.path == "/v1/models":
190
+ self.send_json(200, {
191
+ "object": "list",
192
+ "data": [{"id": MODEL_NAME, "object": "model", "owned_by": "tuned-tensor-local"}],
193
+ })
194
+ return
195
+ self.send_json(404, {"error": {"message": "Not found"}})
196
+
197
+ def do_POST(self) -> None: # noqa: N802
198
+ if not self.authorized():
199
+ self.send_json(401, {"error": {"message": "Unauthorized"}})
200
+ return
201
+ if self.path not in {"/v1/chat/completions", "/chat/completions"}:
202
+ self.send_json(404, {"error": {"message": "Not found"}})
203
+ return
204
+ try:
205
+ length = int(self.headers.get("content-length", "0"))
206
+ if length <= 0 or length > MAX_REQUEST_BYTES:
207
+ raise ValueError("Request body is empty or too large.")
208
+ body = json.loads(self.rfile.read(length))
209
+ if not isinstance(body, dict):
210
+ raise ValueError("Request body must be a JSON object.")
211
+ if body.get("stream"):
212
+ self.send_json(400, {"error": {"message": "Streaming is not supported yet."}})
213
+ return
214
+ messages = normalize_messages(body.get("messages"))
215
+ generation = {
216
+ "max_new_tokens": bounded_integer(body.get("max_tokens"), DEFAULT_MAX_TOKENS, 1, 8192),
217
+ "temperature": bounded_number(body.get("temperature"), DEFAULT_TEMPERATURE, 0, 5),
218
+ "top_p": bounded_number(body.get("top_p"), DEFAULT_TOP_P, 0, 1),
219
+ }
220
+ if not REQUEST_SLOTS.acquire(blocking=False):
221
+ self.send_json(429, {"error": {"message": "The local model is busy; retry shortly."}})
222
+ return
223
+ started = time.perf_counter()
224
+ try:
225
+ content, prompt_tokens, completion_tokens = generate_text(messages, generation)
226
+ finally:
227
+ REQUEST_SLOTS.release()
228
+ latency_ms = round((time.perf_counter() - started) * 1000)
229
+ self.send_json(200, {
230
+ "id": "chatcmpl-" + uuid.uuid4().hex,
231
+ "object": "chat.completion",
232
+ "created": int(time.time()),
233
+ "model": MODEL_NAME,
234
+ "choices": [{
235
+ "index": 0,
236
+ "message": {"role": "assistant", "content": content},
237
+ "finish_reason": "stop",
238
+ }],
239
+ "usage": {
240
+ "prompt_tokens": prompt_tokens,
241
+ "completion_tokens": completion_tokens,
242
+ "total_tokens": prompt_tokens + completion_tokens,
243
+ },
244
+ "tt_local": {"latency_ms": latency_ms, "device": DEVICE},
245
+ })
246
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
247
+ self.send_json(400, {"error": {"message": str(exc)}})
248
+ except Exception as exc:
249
+ print(f"Model server error: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
250
+ self.send_json(500, {"error": {"message": "Internal model server error."}})
251
+
252
+ def log_message(self, fmt: str, *args: Any) -> None:
253
+ print("%s - %s" % (self.address_string(), fmt % args), flush=True)
254
+
255
+
256
+ def main() -> None:
257
+ print(f"Serving {MODEL_NAME} on http://{HOST}:{PORT}", flush=True)
258
+ print(f"OpenAI-compatible endpoint: http://{HOST}:{PORT}/v1/chat/completions", flush=True)
259
+ print(f"Device: {DEVICE}", flush=True)
260
+ server = ThreadingHTTPServer((HOST, PORT), Handler)
261
+ server.daemon_threads = True
262
+ server.request_queue_size = max(2, MAX_CONCURRENT_REQUESTS * 2)
263
+ try:
264
+ server.serve_forever()
265
+ except KeyboardInterrupt:
266
+ print("Model server stopped.", flush=True)
267
+ finally:
268
+ server.server_close()
269
+
270
+
271
+ if __name__ == "__main__":
272
+ main()
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+
7
+ IGNORE_INDEX = -100
8
+
9
+
10
+ def _token_ids(value: Any, description: str) -> list[int]:
11
+ if isinstance(value, Mapping):
12
+ value = value.get("input_ids")
13
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
14
+ raise TypeError(f"{description} did not return a token-id sequence")
15
+ token_ids = list(value)
16
+ if not token_ids or any(not isinstance(token_id, int) for token_id in token_ids):
17
+ raise ValueError(f"{description} returned empty or invalid token ids")
18
+ return token_ids
19
+
20
+
21
+ def build_assistant_only_example(
22
+ tokenizer: Any,
23
+ messages: Any,
24
+ *,
25
+ max_length: int,
26
+ ) -> dict[str, list[int]]:
27
+ """Tokenize one chat row while computing loss only on the final assistant turn.
28
+
29
+ The entire assistant completion is reserved before the prompt is truncated.
30
+ Prompt truncation therefore removes the oldest prompt tokens and can never
31
+ silently discard the expected answer.
32
+ """
33
+ if max_length < 2:
34
+ raise ValueError("max_length must be at least 2")
35
+ if not isinstance(messages, list) or len(messages) < 2:
36
+ raise ValueError("messages must contain prompt context and a final assistant turn")
37
+ if any(not isinstance(message, dict) for message in messages):
38
+ raise ValueError("every message must be an object")
39
+ allowed_roles = {"system", "user", "assistant", "tool"}
40
+ for message in messages:
41
+ role = message.get("role")
42
+ if role not in allowed_roles:
43
+ raise ValueError(f"unsupported message role: {role!r}")
44
+ if not isinstance(message.get("content"), str):
45
+ raise ValueError("the bundled trainer accepts text-only message content")
46
+
47
+ assistant = messages[-1]
48
+ if assistant.get("role") != "assistant":
49
+ raise ValueError("the final message must have role=assistant")
50
+ content = assistant.get("content")
51
+ if not isinstance(content, str) or not content.strip():
52
+ raise ValueError("the final assistant message must contain non-empty text")
53
+
54
+ try:
55
+ prompt_ids = _token_ids(
56
+ tokenizer.apply_chat_template(
57
+ messages[:-1],
58
+ tokenize=True,
59
+ add_generation_prompt=True,
60
+ return_dict=False,
61
+ ),
62
+ "prompt chat template",
63
+ )
64
+ full_ids = _token_ids(
65
+ tokenizer.apply_chat_template(
66
+ messages,
67
+ tokenize=True,
68
+ add_generation_prompt=False,
69
+ return_dict=False,
70
+ ),
71
+ "full chat template",
72
+ )
73
+ except Exception as exc:
74
+ raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
75
+
76
+ if full_ids[: len(prompt_ids)] != prompt_ids:
77
+ raise ValueError(
78
+ "Qwen chat-template output is not prefix-aligned; refusing to guess the assistant loss boundary"
79
+ )
80
+ completion_ids = full_ids[len(prompt_ids) :]
81
+ if not completion_ids:
82
+ raise ValueError("Qwen chat template produced no assistant completion tokens")
83
+ if len(completion_ids) >= max_length:
84
+ raise ValueError(
85
+ f"assistant completion requires {len(completion_ids)} tokens, but max_length={max_length}; "
86
+ "increase max_seq_length so the answer is not truncated"
87
+ )
88
+
89
+ prompt_budget = max_length - len(completion_ids)
90
+ retained_prompt_ids = prompt_ids[-prompt_budget:]
91
+ input_ids = [*retained_prompt_ids, *completion_ids]
92
+ labels = [IGNORE_INDEX] * len(retained_prompt_ids) + completion_ids.copy()
93
+ return {
94
+ "input_ids": input_ids,
95
+ "attention_mask": [1] * len(input_ids),
96
+ "labels": labels,
97
+ }
@@ -0,0 +1,289 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tarfile
6
+ import time
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+ from typing import Any
10
+
11
+ import torch
12
+ from peft import LoraConfig, get_peft_model
13
+ from torch.nn.utils.rnn import pad_sequence
14
+ from torch.utils.data import Dataset
15
+ from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
16
+
17
+ from model_contract import (
18
+ CERTIFIED_BASE_MODEL,
19
+ assert_certified_model_config,
20
+ )
21
+ from sft_data import IGNORE_INDEX, build_assistant_only_example
22
+
23
+
24
+ TRAINING_DIR = Path(os.environ.get("SM_CHANNEL_TRAINING", "/opt/ml/input/data/training"))
25
+ BASE_MODEL_DIR = Path(os.environ.get("SM_CHANNEL_BASE_MODEL", "/opt/ml/input/data/base_model"))
26
+ HYPERPARAMETERS_PATH = Path(
27
+ os.environ.get("TT_HYPERPARAMETERS_PATH", "/opt/ml/input/config/hyperparameters.json")
28
+ )
29
+ MODEL_DIR = Path(os.environ.get("SM_MODEL_DIR", "/opt/ml/model"))
30
+ OUTPUT_DIR = Path(os.environ.get("SM_OUTPUT_DIR", "/opt/ml/output"))
31
+
32
+
33
+ def load_hyperparameters() -> dict[str, str]:
34
+ if not HYPERPARAMETERS_PATH.is_file():
35
+ return {}
36
+ raw = json.loads(HYPERPARAMETERS_PATH.read_text(encoding="utf-8"))
37
+ if not isinstance(raw, dict):
38
+ raise ValueError("Hyperparameters must be a JSON object")
39
+ return {str(key): str(value) for key, value in raw.items()}
40
+
41
+
42
+ HP = load_hyperparameters()
43
+
44
+
45
+ def hp(name: str, default: str | None = None) -> str | None:
46
+ value = os.getenv(f"SM_HP_{name.upper()}", HP.get(name, default))
47
+ if value is None:
48
+ return None
49
+ value = str(value).strip()
50
+ if len(value) >= 2 and value[0] == value[-1] == '"':
51
+ return value[1:-1]
52
+ return value
53
+
54
+
55
+ def hp_int(name: str, default: int) -> int:
56
+ return int(hp(name, str(default)) or default)
57
+
58
+
59
+ def hp_float(name: str, default: float) -> float:
60
+ return float(hp(name, str(default)) or default)
61
+
62
+
63
+ def model_revision_kwargs(model_source: str) -> dict[str, str]:
64
+ revision = hp("base_model_revision")
65
+ return {"revision": revision} if revision and not Path(model_source).exists() else {}
66
+
67
+
68
+ def assert_certified_request() -> None:
69
+ requested_model = hp("base_model", CERTIFIED_BASE_MODEL)
70
+ if requested_model != CERTIFIED_BASE_MODEL:
71
+ raise ValueError(
72
+ f"The bundled trainer currently certifies only {CERTIFIED_BASE_MODEL}; got {requested_model!r}"
73
+ )
74
+ loader = hp("model_loader", "causal_lm")
75
+ if loader != "causal_lm":
76
+ raise ValueError(f"The bundled trainer is text-only and requires model_loader=causal_lm; got {loader!r}")
77
+ def require_cuda() -> dict[str, Any]:
78
+ if not torch.cuda.is_available():
79
+ raise RuntimeError(
80
+ "TT Local bundled training requires an NVIDIA CUDA GPU. "
81
+ "Run this workflow on DGX Spark and verify it first with tt-local doctor."
82
+ )
83
+ device_index = torch.cuda.current_device()
84
+ properties = torch.cuda.get_device_properties(device_index)
85
+ return {
86
+ "device_index": device_index,
87
+ "device_name": properties.name,
88
+ "compute_capability": f"{properties.major}.{properties.minor}",
89
+ "bf16_supported": torch.cuda.is_bf16_supported(),
90
+ }
91
+
92
+
93
+ def load_rows() -> list[dict[str, Any]]:
94
+ rows: list[dict[str, Any]] = []
95
+ for path in sorted(TRAINING_DIR.rglob("*.jsonl")):
96
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
97
+ if not line.strip():
98
+ continue
99
+ try:
100
+ row = json.loads(line)
101
+ except json.JSONDecodeError as exc:
102
+ raise ValueError(f"{path}:{line_number} contains invalid JSON") from exc
103
+ if not isinstance(row, dict) or "messages" not in row:
104
+ raise ValueError(f"{path}:{line_number} must contain a messages array")
105
+ rows.append(row)
106
+ if not rows:
107
+ raise ValueError(f"No chat JSONL rows found under {TRAINING_DIR}")
108
+ return rows
109
+
110
+
111
+ def resolve_model_source() -> str:
112
+ if BASE_MODEL_DIR.is_dir() and any(BASE_MODEL_DIR.iterdir()):
113
+ return str(BASE_MODEL_DIR)
114
+ return CERTIFIED_BASE_MODEL
115
+
116
+
117
+ class AssistantOnlyDataset(Dataset[dict[str, torch.Tensor]]):
118
+ def __init__(
119
+ self,
120
+ rows: list[dict[str, Any]],
121
+ tokenizer: Any,
122
+ *,
123
+ max_length: int,
124
+ ):
125
+ self.examples: list[dict[str, torch.Tensor]] = []
126
+ for row_index, row in enumerate(rows, start=1):
127
+ try:
128
+ tokenized = build_assistant_only_example(
129
+ tokenizer,
130
+ row["messages"],
131
+ max_length=max_length,
132
+ )
133
+ except Exception as exc:
134
+ raise ValueError(f"Training row {row_index} is not valid Qwen SFT data: {exc}") from exc
135
+ self.examples.append({
136
+ key: torch.tensor(value, dtype=torch.long)
137
+ for key, value in tokenized.items()
138
+ })
139
+
140
+ def __len__(self) -> int:
141
+ return len(self.examples)
142
+
143
+ def __getitem__(self, index: int) -> dict[str, torch.Tensor]:
144
+ return self.examples[index]
145
+
146
+
147
+ class AssistantOnlyCollator:
148
+ def __init__(self, pad_token_id: int):
149
+ self.pad_token_id = pad_token_id
150
+
151
+ def __call__(self, features: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]:
152
+ return {
153
+ "input_ids": pad_sequence(
154
+ [feature["input_ids"] for feature in features],
155
+ batch_first=True,
156
+ padding_value=self.pad_token_id,
157
+ ),
158
+ "attention_mask": pad_sequence(
159
+ [feature["attention_mask"] for feature in features],
160
+ batch_first=True,
161
+ padding_value=0,
162
+ ),
163
+ "labels": pad_sequence(
164
+ [feature["labels"] for feature in features],
165
+ batch_first=True,
166
+ padding_value=IGNORE_INDEX,
167
+ ),
168
+ }
169
+
170
+
171
+ def create_model_and_tokenizer(model_source: str) -> tuple[Any, Any, torch.dtype]:
172
+ token = os.getenv("HF_TOKEN")
173
+ source_kwargs: dict[str, Any] = {
174
+ **model_revision_kwargs(model_source),
175
+ "trust_remote_code": False,
176
+ "token": token,
177
+ }
178
+ if not Path(model_source).exists():
179
+ source_kwargs["local_files_only"] = True
180
+ tokenizer = AutoTokenizer.from_pretrained(model_source, **source_kwargs)
181
+ if tokenizer.eos_token_id is None:
182
+ raise ValueError(f"{CERTIFIED_BASE_MODEL} tokenizer has no EOS token")
183
+ if tokenizer.pad_token_id is None:
184
+ tokenizer.pad_token = tokenizer.eos_token
185
+
186
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
187
+ model = AutoModelForCausalLM.from_pretrained(
188
+ model_source,
189
+ **source_kwargs,
190
+ dtype=dtype,
191
+ device_map={"": torch.cuda.current_device()},
192
+ )
193
+ assert_certified_model_config(model.config)
194
+ model.config.use_cache = False
195
+ return model, tokenizer, dtype
196
+
197
+
198
+ def apply_lora(model: Any) -> Any:
199
+ config = LoraConfig(
200
+ r=hp_int("lora_rank", 16),
201
+ lora_alpha=hp_int("lora_alpha", 32),
202
+ lora_dropout=hp_float("lora_dropout", 0.05),
203
+ bias="none",
204
+ task_type="CAUSAL_LM",
205
+ target_modules="all-linear",
206
+ )
207
+ return get_peft_model(model, config)
208
+
209
+
210
+ def create_model_archive() -> Path:
211
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
212
+ archive_path = OUTPUT_DIR / "model.tar.gz"
213
+ with tarfile.open(archive_path, "w:gz") as archive:
214
+ archive.add(MODEL_DIR, arcname="model")
215
+ return archive_path
216
+
217
+
218
+ def run_training(rows: list[dict[str, Any]], model_source: str) -> tuple[dict[str, Any], torch.dtype]:
219
+ model, tokenizer, dtype = create_model_and_tokenizer(model_source)
220
+ model = apply_lora(model)
221
+ dataset = AssistantOnlyDataset(
222
+ rows,
223
+ tokenizer,
224
+ max_length=hp_int("max_seq_length", 2048),
225
+ )
226
+ collator = AssistantOnlyCollator(tokenizer.pad_token_id)
227
+
228
+ with TemporaryDirectory(prefix="tt-local-sft-") as output_dir:
229
+ args = TrainingArguments(
230
+ output_dir=output_dir,
231
+ num_train_epochs=hp_int("n_epochs", 3),
232
+ learning_rate=hp_float("learning_rate", 0.00001),
233
+ per_device_train_batch_size=hp_int("per_device_train_batch_size", 1),
234
+ gradient_accumulation_steps=hp_int("gradient_accumulation_steps", 8),
235
+ logging_steps=1,
236
+ save_strategy="no",
237
+ report_to=[],
238
+ remove_unused_columns=False,
239
+ bf16=dtype == torch.bfloat16,
240
+ fp16=dtype == torch.float16,
241
+ optim="adamw_torch",
242
+ )
243
+ trainer = Trainer(
244
+ model=model,
245
+ args=args,
246
+ train_dataset=dataset,
247
+ data_collator=collator,
248
+ )
249
+ result = trainer.train()
250
+
251
+ model.save_pretrained(MODEL_DIR)
252
+ tokenizer.save_pretrained(MODEL_DIR)
253
+ return result.metrics, dtype
254
+
255
+
256
+ def main() -> None:
257
+ started = time.time()
258
+ assert_certified_request()
259
+ cuda = require_cuda()
260
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
261
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
262
+
263
+ rows = load_rows()
264
+ model_source = resolve_model_source()
265
+ metrics, dtype = run_training(rows, model_source)
266
+ archive_path = create_model_archive()
267
+ output_metrics = {
268
+ "training_rows": len(rows),
269
+ "base_model": CERTIFIED_BASE_MODEL,
270
+ "base_model_revision": hp("base_model_revision"),
271
+ "model_source": model_source,
272
+ "model_loader": "causal_lm",
273
+ "loss_mask": "final_assistant_only",
274
+ "device": "cuda",
275
+ "cuda_device": cuda["device_name"],
276
+ "cuda_compute_capability": cuda["compute_capability"],
277
+ "dtype": str(dtype).removeprefix("torch."),
278
+ "train_runtime": round(time.time() - started, 3),
279
+ **{key: float(value) for key, value in metrics.items() if isinstance(value, (int, float))},
280
+ "model_archive": str(archive_path),
281
+ }
282
+ metrics_json = json.dumps(output_metrics, indent=2)
283
+ (MODEL_DIR / "training-metrics.json").write_text(metrics_json, encoding="utf-8")
284
+ (OUTPUT_DIR / "training-metrics.json").write_text(metrics_json, encoding="utf-8")
285
+ print(metrics_json)
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()