@tuned-tensor/cli 0.4.11 → 0.4.13

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/dist/index.js CHANGED
@@ -615,8 +615,13 @@ function printDiagnostics(diagnostics) {
615
615
  ["Stage", diagnostics.stage_label ?? diagnostics.stage ?? void 0],
616
616
  ["Summary", diagnostics.summary],
617
617
  ["Training", diagnostics.training.state],
618
+ ["Phase", diagnostics.training.phase ?? void 0],
618
619
  ["Epoch", formatEpochProgress(diagnostics)],
619
620
  ["Latest Loss", curve.latest_loss == null ? void 0 : curve.latest_loss.toFixed(4)],
621
+ [
622
+ "Token Accuracy",
623
+ curve.latest_token_accuracy == null ? void 0 : (curve.latest_token_accuracy * 100).toFixed(1) + "%"
624
+ ],
620
625
  ["Pace", perFive ? `${perFive} epoch / 5m` : void 0],
621
626
  ["ETA", formatMinutes(curve.estimated_minutes_remaining)],
622
627
  ["Latest Update", formatDate(diagnostics.training.last_updated_at)]
@@ -1004,17 +1009,374 @@ function registerDatasetsCommands(parent) {
1004
1009
  }
1005
1010
 
1006
1011
  // src/commands/models.ts
1007
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, statSync as statSync3, createWriteStream, unlinkSync } from "fs";
1008
- import { dirname, join as join2 } from "path";
1009
- import { Readable } from "stream";
1012
+ import { spawn, execFileSync } from "child_process";
1013
+ import { createHash } from "crypto";
1014
+ import {
1015
+ existsSync as existsSync4,
1016
+ mkdirSync as mkdirSync2,
1017
+ statSync as statSync3,
1018
+ createWriteStream,
1019
+ unlinkSync,
1020
+ readFileSync as readFileSync6,
1021
+ writeFileSync as writeFileSync3,
1022
+ readdirSync,
1023
+ rmSync
1024
+ } from "fs";
1025
+ import { homedir as homedir2 } from "os";
1026
+ import { dirname, join as join2, resolve as resolve2, basename as basename2 } from "path";
1027
+ import { Readable, Transform } from "stream";
1010
1028
  import { pipeline } from "stream/promises";
1029
+
1030
+ // src/commands/init.ts
1031
+ import { existsSync as existsSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1032
+ import { resolve } from "path";
1033
+ var DEFAULT_SPEC_FILE = "tunedtensor.json";
1034
+ var SCAFFOLD = {
1035
+ name: "My Agent",
1036
+ description: "",
1037
+ base_model: "Qwen/Qwen3.5-2B",
1038
+ system_prompt: "You are a helpful assistant.",
1039
+ guidelines: [],
1040
+ constraints: [],
1041
+ examples: [
1042
+ { input: "Hello", output: "Hi! How can I help you today?" }
1043
+ ],
1044
+ eval_cases: []
1045
+ };
1046
+ function registerInitCommand(parent) {
1047
+ parent.command("init").description("Create a local behaviour spec file").option("-n, --name <name>", "Spec name").option("--model <model>", "Base model ID").option("-f, --file <path>", "Output file path", DEFAULT_SPEC_FILE).action(async (cmdOpts) => {
1048
+ const filePath = resolve(cmdOpts.file);
1049
+ if (existsSync3(filePath)) {
1050
+ printWarning(`${cmdOpts.file} already exists. Use tt eval to validate it or edit it directly.`);
1051
+ return;
1052
+ }
1053
+ const spec = { ...SCAFFOLD };
1054
+ if (cmdOpts.name) spec.name = cmdOpts.name;
1055
+ if (cmdOpts.model) spec.base_model = canonicalizeBaseModel(cmdOpts.model);
1056
+ writeFileSync2(filePath, JSON.stringify(spec, null, 2) + "\n");
1057
+ printSuccess(`Created ${cmdOpts.file}`);
1058
+ console.log("\nNext steps:");
1059
+ console.log(" 1. Edit the spec: system_prompt, guidelines, examples");
1060
+ console.log(" 2. Validate spec: tt eval");
1061
+ console.log(" 3. Push to remote: tt push");
1062
+ });
1063
+ }
1064
+ function loadSpec(filePath) {
1065
+ const resolved = resolve(filePath || DEFAULT_SPEC_FILE);
1066
+ if (!existsSync3(resolved)) {
1067
+ printError(
1068
+ `Spec file not found: ${filePath || DEFAULT_SPEC_FILE}
1069
+ Run \`tt init\` to create one.`
1070
+ );
1071
+ process.exit(1);
1072
+ }
1073
+ const raw = JSON.parse(readFileSync5(resolved, "utf-8"));
1074
+ return raw;
1075
+ }
1076
+
1077
+ // src/commands/models.ts
1078
+ var REFERENCE_SERVER_SCRIPT = String.raw`#!/usr/bin/env python3
1079
+ import json
1080
+ import os
1081
+ import time
1082
+ import uuid
1083
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1084
+
1085
+ MODEL_PATH = os.environ["TT_MODEL_PATH"]
1086
+ MODEL_NAME = os.environ.get("TT_MODEL_NAME", os.path.basename(MODEL_PATH.rstrip("/")) or "tuned-tensor-model")
1087
+ SYSTEM_PROMPT = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
1088
+ HOST = os.environ.get("TT_HOST", "127.0.0.1")
1089
+ PORT = int(os.environ.get("TT_PORT", "8000"))
1090
+ DEFAULT_MAX_TOKENS = int(os.environ.get("TT_MAX_TOKENS", "512"))
1091
+ DEFAULT_TEMPERATURE = float(os.environ.get("TT_TEMPERATURE", "0.7"))
1092
+ TRUST_REMOTE_CODE = os.environ.get("TT_TRUST_REMOTE_CODE", "false").lower() in {"1", "true", "yes", "y"}
1093
+ REQUESTED_DEVICE = os.environ.get("TT_DEVICE", "auto").lower()
1094
+
1095
+
1096
+ def choose_device(torch):
1097
+ if REQUESTED_DEVICE not in {"auto", "cpu", "cuda", "mps"}:
1098
+ raise RuntimeError("TT_DEVICE must be one of: auto, cpu, cuda, mps")
1099
+ if REQUESTED_DEVICE == "cuda" and not torch.cuda.is_available():
1100
+ raise RuntimeError("TT_DEVICE=cuda requested, but CUDA is not available")
1101
+ if REQUESTED_DEVICE == "mps" and not (
1102
+ hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
1103
+ ):
1104
+ raise RuntimeError("TT_DEVICE=mps requested, but Apple MPS is not available")
1105
+ if REQUESTED_DEVICE != "auto":
1106
+ return REQUESTED_DEVICE
1107
+ if torch.cuda.is_available():
1108
+ return "cuda"
1109
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
1110
+ return "mps"
1111
+ return "cpu"
1112
+
1113
+
1114
+ def load_model():
1115
+ try:
1116
+ import torch
1117
+ from transformers import (
1118
+ AutoModelForCausalLM,
1119
+ AutoModelForImageTextToText,
1120
+ AutoProcessor,
1121
+ AutoTokenizer,
1122
+ )
1123
+ except Exception as exc:
1124
+ raise RuntimeError(
1125
+ "Missing Python serving dependencies. Install them with: "
1126
+ "python -m pip install torch transformers accelerate"
1127
+ ) from exc
1128
+
1129
+ try:
1130
+ processor = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
1131
+ except Exception:
1132
+ processor = AutoProcessor.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
1133
+ tokenizer = getattr(processor, "tokenizer", processor)
1134
+ if getattr(tokenizer, "pad_token", None) is None:
1135
+ tokenizer.pad_token = tokenizer.eos_token
1136
+
1137
+ device = choose_device(torch)
1138
+ kwargs = {
1139
+ "trust_remote_code": TRUST_REMOTE_CODE,
1140
+ "torch_dtype": "auto",
1141
+ "low_cpu_mem_usage": True,
1142
+ }
1143
+ if device == "cuda":
1144
+ kwargs["device_map"] = "auto"
1145
+ elif device == "mps":
1146
+ kwargs["torch_dtype"] = torch.float16
1147
+
1148
+ try:
1149
+ model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **kwargs)
1150
+ except Exception:
1151
+ model = AutoModelForImageTextToText.from_pretrained(MODEL_PATH, **kwargs)
1152
+ if device != "cuda":
1153
+ model.to(device)
1154
+ model.eval()
1155
+ return torch, tokenizer, model, device
1156
+
1157
+
1158
+ torch, tokenizer, model, DEVICE = load_model()
1159
+
1160
+
1161
+ def content_to_text(content):
1162
+ if isinstance(content, str):
1163
+ return content
1164
+ return json.dumps(content, ensure_ascii=False)
1165
+
1166
+
1167
+ def normalize_messages(raw_messages):
1168
+ messages = []
1169
+ for item in raw_messages:
1170
+ if not isinstance(item, dict):
1171
+ continue
1172
+ role = item.get("role")
1173
+ if role not in {"system", "user", "assistant", "tool"}:
1174
+ continue
1175
+ messages.append({"role": role, "content": content_to_text(item.get("content", ""))})
1176
+
1177
+ if SYSTEM_PROMPT and not any(message["role"] == "system" for message in messages):
1178
+ messages.insert(0, {"role": "system", "content": SYSTEM_PROMPT})
1179
+ return messages
1180
+
1181
+
1182
+ def render_prompt(messages):
1183
+ chat_template = getattr(tokenizer, "chat_template", None)
1184
+ if hasattr(tokenizer, "apply_chat_template") and chat_template:
1185
+ return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
1186
+
1187
+ rendered = []
1188
+ for message in messages:
1189
+ rendered.append(f"{message['role'].upper()}: {message['content']}")
1190
+ rendered.append("ASSISTANT:")
1191
+ return "\n".join(rendered)
1192
+
1193
+
1194
+ def generate_completion(messages, max_tokens, temperature):
1195
+ prompt = render_prompt(messages)
1196
+ inputs = tokenizer(prompt, return_tensors="pt")
1197
+ input_device = getattr(model, "device", None)
1198
+ if input_device is None:
1199
+ input_device = next(model.parameters()).device
1200
+ inputs = {key: value.to(input_device) for key, value in inputs.items()}
1201
+
1202
+ generate_kwargs = {
1203
+ **inputs,
1204
+ "max_new_tokens": max_tokens,
1205
+ "pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
1206
+ }
1207
+ if temperature > 0:
1208
+ generate_kwargs["do_sample"] = True
1209
+ generate_kwargs["temperature"] = temperature
1210
+ else:
1211
+ generate_kwargs["do_sample"] = False
1212
+
1213
+ with torch.no_grad():
1214
+ output_ids = model.generate(**generate_kwargs)
1215
+
1216
+ prompt_tokens = int(inputs["input_ids"].shape[-1])
1217
+ completion_ids = output_ids[0][prompt_tokens:]
1218
+ content = tokenizer.decode(completion_ids, skip_special_tokens=True).strip()
1219
+ return content, prompt_tokens, int(completion_ids.shape[-1])
1220
+
1221
+
1222
+ class Handler(BaseHTTPRequestHandler):
1223
+ server_version = "TunedTensorReferenceServer/0.1"
1224
+
1225
+ def send_json(self, status, payload):
1226
+ body = json.dumps(payload).encode("utf-8")
1227
+ self.send_response(status)
1228
+ self.send_header("Content-Type", "application/json")
1229
+ self.send_header("Content-Length", str(len(body)))
1230
+ self.send_header("Access-Control-Allow-Origin", "*")
1231
+ self.send_header("Access-Control-Allow-Headers", "authorization, content-type")
1232
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
1233
+ self.end_headers()
1234
+ self.wfile.write(body)
1235
+
1236
+ def do_OPTIONS(self):
1237
+ self.send_json(204, {})
1238
+
1239
+ def do_GET(self):
1240
+ if self.path == "/health":
1241
+ self.send_json(200, {
1242
+ "status": "ok",
1243
+ "model": MODEL_NAME,
1244
+ "spec_prompt": bool(SYSTEM_PROMPT),
1245
+ "device": DEVICE,
1246
+ })
1247
+ return
1248
+ self.send_json(404, {"error": {"message": "Not found"}})
1249
+
1250
+ def do_POST(self):
1251
+ if self.path not in {"/v1/chat/completions", "/chat/completions"}:
1252
+ self.send_json(404, {"error": {"message": "Not found"}})
1253
+ return
1254
+
1255
+ try:
1256
+ length = int(self.headers.get("content-length", "0"))
1257
+ body = json.loads(self.rfile.read(length) or b"{}")
1258
+ if body.get("stream"):
1259
+ self.send_json(400, {"error": {"message": "Streaming is not supported by tt models serve yet."}})
1260
+ return
1261
+
1262
+ raw_messages = body.get("messages")
1263
+ if not isinstance(raw_messages, list) or not raw_messages:
1264
+ self.send_json(400, {"error": {"message": "Request must include a non-empty messages array."}})
1265
+ return
1266
+
1267
+ max_tokens = int(body.get("max_tokens") or DEFAULT_MAX_TOKENS)
1268
+ temperature = float(body.get("temperature") if body.get("temperature") is not None else DEFAULT_TEMPERATURE)
1269
+ messages = normalize_messages(raw_messages)
1270
+ content, prompt_tokens, completion_tokens = generate_completion(messages, max_tokens, temperature)
1271
+
1272
+ self.send_json(200, {
1273
+ "id": "chatcmpl-" + uuid.uuid4().hex,
1274
+ "object": "chat.completion",
1275
+ "created": int(time.time()),
1276
+ "model": body.get("model") or MODEL_NAME,
1277
+ "choices": [{
1278
+ "index": 0,
1279
+ "message": {"role": "assistant", "content": content},
1280
+ "finish_reason": "stop",
1281
+ }],
1282
+ "usage": {
1283
+ "prompt_tokens": prompt_tokens,
1284
+ "completion_tokens": completion_tokens,
1285
+ "total_tokens": prompt_tokens + completion_tokens,
1286
+ },
1287
+ })
1288
+ except Exception as exc:
1289
+ self.send_json(500, {"error": {"message": str(exc)}})
1290
+
1291
+ def log_message(self, fmt, *args):
1292
+ print("%s - %s" % (self.address_string(), fmt % args))
1293
+
1294
+
1295
+ def main():
1296
+ print(f"Serving {MODEL_NAME} from {MODEL_PATH}")
1297
+ print(f"Listening on http://{HOST}:{PORT}/v1/chat/completions")
1298
+ if SYSTEM_PROMPT:
1299
+ print("Behavior spec prompt: enabled")
1300
+ else:
1301
+ print("Behavior spec prompt: disabled")
1302
+ print(f"Device: {DEVICE}")
1303
+ ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
1304
+
1305
+
1306
+ if __name__ == "__main__":
1307
+ main()
1308
+ `;
1011
1309
  function resolveOutputPath(output, filename) {
1012
1310
  if (!output) return filename;
1013
- if (existsSync3(output) && statSync3(output).isDirectory()) {
1311
+ if (existsSync4(output) && statSync3(output).isDirectory()) {
1014
1312
  return join2(output, filename);
1015
1313
  }
1016
1314
  return output;
1017
1315
  }
1316
+ function parseContentLength(value) {
1317
+ if (!value) return null;
1318
+ const parsed = Number(value);
1319
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
1320
+ }
1321
+ function formatBytes2(bytes) {
1322
+ const units = ["B", "KB", "MB", "GB", "TB"];
1323
+ let value = bytes;
1324
+ let unit = 0;
1325
+ while (value >= 1024 && unit < units.length - 1) {
1326
+ value /= 1024;
1327
+ unit += 1;
1328
+ }
1329
+ const precision = unit === 0 || value >= 10 ? 0 : 1;
1330
+ return `${value.toFixed(precision)} ${units[unit]}`;
1331
+ }
1332
+ function formatDuration(seconds) {
1333
+ if (!Number.isFinite(seconds) || seconds < 0) return "--";
1334
+ const rounded = Math.max(1, Math.round(seconds));
1335
+ const hours = Math.floor(rounded / 3600);
1336
+ const minutes = Math.floor(rounded % 3600 / 60);
1337
+ const secs = rounded % 60;
1338
+ if (hours > 0) return `${hours}h ${minutes}m`;
1339
+ if (minutes > 0) return `${minutes}m ${secs}s`;
1340
+ return `${secs}s`;
1341
+ }
1342
+ function chunkLength(chunk) {
1343
+ if (typeof chunk === "string") return Buffer.byteLength(chunk);
1344
+ if (chunk instanceof Uint8Array) return chunk.byteLength;
1345
+ return Buffer.byteLength(String(chunk));
1346
+ }
1347
+ function createDownloadProgress(totalBytes, enabled) {
1348
+ const stream = process.stderr;
1349
+ const start = Date.now();
1350
+ let lastRender = 0;
1351
+ let rendered = false;
1352
+ function render(downloadedBytes, force = false) {
1353
+ if (!enabled) return;
1354
+ const now = Date.now();
1355
+ if (!force && now - lastRender < 100 && downloadedBytes !== totalBytes) return;
1356
+ lastRender = now;
1357
+ rendered = true;
1358
+ const elapsedSeconds = Math.max((now - start) / 1e3, 1e-3);
1359
+ const rate = downloadedBytes / elapsedSeconds;
1360
+ const rateText = `${formatBytes2(rate)}/s`;
1361
+ let line;
1362
+ if (totalBytes != null && totalBytes > 0) {
1363
+ const ratio = Math.min(downloadedBytes / totalBytes, 1);
1364
+ const barWidth = 24;
1365
+ const filled = Math.round(ratio * barWidth);
1366
+ const bar = "#".repeat(filled) + "-".repeat(barWidth - filled);
1367
+ const etaSeconds = rate > 0 ? (totalBytes - downloadedBytes) / rate : Number.NaN;
1368
+ line = `Downloading [${bar}] ${(ratio * 100).toFixed(1).padStart(5)}% ${formatBytes2(downloadedBytes)}/${formatBytes2(totalBytes)} ${rateText} ETA ${formatDuration(etaSeconds)}`;
1369
+ } else {
1370
+ line = `Downloading ${formatBytes2(downloadedBytes)} ${rateText} elapsed ${formatDuration(elapsedSeconds)}`;
1371
+ }
1372
+ const columns = stream.columns || 120;
1373
+ stream.write(`\r\x1B[K${line.slice(0, Math.max(columns - 1, 0))}`);
1374
+ }
1375
+ function stop() {
1376
+ if (enabled && rendered) stream.write("\n");
1377
+ }
1378
+ return { render, stop };
1379
+ }
1018
1380
  async function downloadUrlToFile(url, outputPath) {
1019
1381
  const res = await fetch(url);
1020
1382
  if (!res.ok) {
@@ -1025,17 +1387,225 @@ async function downloadUrlToFile(url, outputPath) {
1025
1387
  }
1026
1388
  mkdirSync2(dirname(outputPath), { recursive: true });
1027
1389
  const file = createWriteStream(outputPath);
1390
+ const contentLength = parseContentLength(res.headers.get("content-length"));
1391
+ const progress = createDownloadProgress(
1392
+ contentLength,
1393
+ !isJsonMode() && process.stderr.isTTY === true
1394
+ );
1395
+ let downloadedBytes = 0;
1396
+ const progressStream = new Transform({
1397
+ transform(chunk, _encoding, callback) {
1398
+ downloadedBytes += chunkLength(chunk);
1399
+ progress.render(downloadedBytes);
1400
+ callback(null, chunk);
1401
+ }
1402
+ });
1028
1403
  try {
1029
- await pipeline(Readable.fromWeb(res.body), file);
1404
+ progress.render(0, true);
1405
+ await pipeline(Readable.fromWeb(res.body), progressStream, file);
1406
+ progress.render(downloadedBytes, true);
1407
+ progress.stop();
1030
1408
  } catch (err) {
1409
+ progress.stop();
1031
1410
  try {
1032
1411
  unlinkSync(outputPath);
1033
1412
  } catch {
1034
1413
  }
1035
1414
  throw err;
1036
1415
  }
1037
- const length = res.headers.get("content-length");
1038
- return length ? Number(length) : null;
1416
+ return contentLength;
1417
+ }
1418
+ function defaultCacheDir() {
1419
+ const root = process.env.XDG_CACHE_HOME || join2(homedir2(), ".cache");
1420
+ return join2(root, "tuned-tensor", "models");
1421
+ }
1422
+ function runtimeDir(cacheDir) {
1423
+ return join2(cacheDir, "_runtime");
1424
+ }
1425
+ function runtimePythonPath(cacheDir) {
1426
+ const dir = runtimeDir(cacheDir);
1427
+ return process.platform === "win32" ? join2(dir, "Scripts", "python.exe") : join2(dir, "bin", "python");
1428
+ }
1429
+ function resolveServePython(cacheDir, explicitPython) {
1430
+ if (explicitPython) return explicitPython;
1431
+ const managedPython = runtimePythonPath(cacheDir);
1432
+ return existsSync4(managedPython) ? managedPython : "python3";
1433
+ }
1434
+ function commandExists(command) {
1435
+ try {
1436
+ execFileSync(command, ["--version"], { stdio: "ignore" });
1437
+ return true;
1438
+ } catch {
1439
+ return false;
1440
+ }
1441
+ }
1442
+ function pythonMinorVersion(command) {
1443
+ try {
1444
+ const out = execFileSync(
1445
+ command,
1446
+ ["-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"],
1447
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
1448
+ ).trim();
1449
+ const [major, minor] = out.split(".").map((part) => Number(part));
1450
+ if (major !== 3 || !Number.isFinite(minor)) return null;
1451
+ return minor;
1452
+ } catch {
1453
+ return null;
1454
+ }
1455
+ }
1456
+ function pickRuntimePython(explicitPython) {
1457
+ const candidates = explicitPython ? [explicitPython] : ["python3.12", "python3.11", "python3.10", "python3"];
1458
+ for (const candidate of candidates) {
1459
+ if (!commandExists(candidate)) continue;
1460
+ const minor = pythonMinorVersion(candidate);
1461
+ if (minor != null && minor >= 10 && minor <= 12) return candidate;
1462
+ if (explicitPython) {
1463
+ throw new Error(
1464
+ `${candidate} is Python 3.${minor ?? "?"}. The local serving runtime requires Python 3.10, 3.11, or 3.12 because torch wheels may not be available for newer versions.`
1465
+ );
1466
+ }
1467
+ }
1468
+ throw new Error(
1469
+ "Could not find Python 3.10, 3.11, or 3.12. Install one, then run `tt models setup-runtime --python <path>`."
1470
+ );
1471
+ }
1472
+ function ensureServingRuntime(python, cacheDir) {
1473
+ const check = `
1474
+ import importlib.util
1475
+ import json
1476
+ required = ["torch", "transformers", "accelerate", "safetensors"]
1477
+ missing = [name for name in required if importlib.util.find_spec(name) is None]
1478
+ print(json.dumps({"missing": missing}))
1479
+ `;
1480
+ let missing;
1481
+ try {
1482
+ const out = execFileSync(python, ["-c", check], {
1483
+ encoding: "utf8",
1484
+ stdio: ["ignore", "pipe", "pipe"]
1485
+ });
1486
+ missing = JSON.parse(out).missing;
1487
+ } catch (err) {
1488
+ const suffix = python === runtimePythonPath(cacheDir) ? " Re-run `tt models setup-runtime` to repair it." : " Run `tt models setup-runtime` to create an isolated serving runtime.";
1489
+ throw new Error(`Python serving runtime check failed for ${python}.${suffix}`);
1490
+ }
1491
+ if (missing.length > 0) {
1492
+ const managedPython = runtimePythonPath(cacheDir);
1493
+ const installHint = python === managedPython ? "Run `tt models setup-runtime --force` to repair it." : "Run `tt models setup-runtime` first, or pass --python <path> to a Python environment with the serving dependencies installed.";
1494
+ throw new Error(
1495
+ `Python serving runtime is missing: ${missing.join(", ")}. ${installHint}`
1496
+ );
1497
+ }
1498
+ }
1499
+ function setupRuntimeCommands(python, venvDir, deps) {
1500
+ const venvPython = process.platform === "win32" ? join2(venvDir, "Scripts", "python.exe") : join2(venvDir, "bin", "python");
1501
+ return [
1502
+ [python, "-m", "venv", venvDir],
1503
+ [venvPython, "-m", "pip", "install", "--upgrade", "pip"],
1504
+ [venvPython, "-m", "pip", "install", ...deps]
1505
+ ];
1506
+ }
1507
+ function safeSegment(value) {
1508
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "model";
1509
+ }
1510
+ function directoryHasFiles(path) {
1511
+ return existsSync4(path) && statSync3(path).isDirectory() && readdirSync(path).length > 0;
1512
+ }
1513
+ function extractArchive(archivePath, targetDir, force) {
1514
+ if (directoryHasFiles(targetDir) && !force) return targetDir;
1515
+ if (existsSync4(targetDir)) rmSync(targetDir, { recursive: true, force: true });
1516
+ mkdirSync2(targetDir, { recursive: true });
1517
+ execFileSync("tar", ["-xzf", archivePath, "-C", targetDir], { stdio: "inherit" });
1518
+ return targetDir;
1519
+ }
1520
+ function localArchiveCacheDir(archivePath, cacheDir) {
1521
+ const resolved = resolve2(archivePath);
1522
+ const digest = createHash("sha256").update(resolved).digest("hex").slice(0, 12);
1523
+ return join2(cacheDir, `local-${safeSegment(basename2(archivePath))}-${digest}`);
1524
+ }
1525
+ function buildSystemPrompt(spec) {
1526
+ const parts = [];
1527
+ if (spec.system_prompt?.trim()) parts.push(spec.system_prompt.trim());
1528
+ if (Array.isArray(spec.guidelines) && spec.guidelines.length > 0) {
1529
+ parts.push(`Guidelines:
1530
+ ${spec.guidelines.map((g) => `- ${g}`).join("\n")}`);
1531
+ }
1532
+ if (Array.isArray(spec.constraints) && spec.constraints.length > 0) {
1533
+ parts.push(`Constraints:
1534
+ ${spec.constraints.map((c) => `- ${c}`).join("\n")}`);
1535
+ }
1536
+ return parts.join("\n\n");
1537
+ }
1538
+ function findSpecPath(explicitPath, modelPath) {
1539
+ const candidates = explicitPath ? [explicitPath] : [join2(process.cwd(), DEFAULT_SPEC_FILE), join2(modelPath, DEFAULT_SPEC_FILE)];
1540
+ for (const candidate of candidates) {
1541
+ const resolved = resolve2(candidate);
1542
+ if (existsSync4(resolved) && statSync3(resolved).isFile()) return resolved;
1543
+ }
1544
+ return null;
1545
+ }
1546
+ function loadSystemPromptFromSpec(specPath) {
1547
+ const spec = JSON.parse(readFileSync6(specPath, "utf8"));
1548
+ return buildSystemPrompt(spec);
1549
+ }
1550
+ function writeReferenceServerScript(cacheDir) {
1551
+ const scriptDir = join2(cacheDir, "_server");
1552
+ mkdirSync2(scriptDir, { recursive: true });
1553
+ const scriptPath = join2(scriptDir, "openai_reference_server.py");
1554
+ writeFileSync3(scriptPath, REFERENCE_SERVER_SCRIPT, "utf8");
1555
+ return scriptPath;
1556
+ }
1557
+ async function resolveServeTarget(target, opts, cacheDir, forceDownload) {
1558
+ if (existsSync4(target)) {
1559
+ const resolved = resolve2(target);
1560
+ const stats = statSync3(resolved);
1561
+ if (stats.isDirectory()) {
1562
+ return {
1563
+ modelPath: resolved,
1564
+ modelName: basename2(resolved),
1565
+ source: "local-directory"
1566
+ };
1567
+ }
1568
+ if (stats.isFile() && (resolved.endsWith(".tar.gz") || resolved.endsWith(".tgz"))) {
1569
+ const extractDir2 = join2(localArchiveCacheDir(resolved, cacheDir), "model");
1570
+ return {
1571
+ modelPath: extractArchive(resolved, extractDir2, forceDownload),
1572
+ modelName: basename2(resolved).replace(/\.t(ar\.)?gz$/, ""),
1573
+ source: "local-archive"
1574
+ };
1575
+ }
1576
+ throw new Error(`Unsupported model path: ${target}. Use a model directory or .tar.gz artifact.`);
1577
+ }
1578
+ const fullId = await resolveModelId(target, opts);
1579
+ const { data: model } = await get(`/models/${fullId}`, void 0, opts);
1580
+ const { data } = await get(`/models/${fullId}/download`, void 0, opts);
1581
+ const modelCacheDir = join2(cacheDir, fullId);
1582
+ const archivePath = join2(modelCacheDir, data.filename);
1583
+ const extractDir = join2(modelCacheDir, "model");
1584
+ if (!existsSync4(archivePath) || forceDownload) {
1585
+ await downloadUrlToFile(data.url, archivePath);
1586
+ }
1587
+ extractArchive(archivePath, extractDir, forceDownload);
1588
+ return {
1589
+ modelPath: extractDir,
1590
+ modelName: model.name || fullId,
1591
+ source: "downloaded-model",
1592
+ modelId: fullId
1593
+ };
1594
+ }
1595
+ function quoteCommandPart(part) {
1596
+ if (/^[a-zA-Z0-9_./:=@-]+$/.test(part)) return part;
1597
+ return `'${part.replace(/'/g, "'\\''")}'`;
1598
+ }
1599
+ function printServeCommand(command, args, env) {
1600
+ const envKeys = Object.keys(env).sort();
1601
+ const commandLine = [command, ...args].map(quoteCommandPart).join(" ");
1602
+ if (isJsonMode()) {
1603
+ return printJson({ command, args, env_keys: envKeys, command_line: commandLine });
1604
+ }
1605
+ printDetail([
1606
+ ["Command", commandLine],
1607
+ ["Environment", envKeys.join(", ")]
1608
+ ]);
1039
1609
  }
1040
1610
  function registerModelsCommands(parent) {
1041
1611
  const models = parent.command("models").description("Manage fine-tuned models");
@@ -1095,7 +1665,7 @@ function registerModelsCommands(parent) {
1095
1665
  opts
1096
1666
  );
1097
1667
  const outputPath = resolveOutputPath(cmdOpts.output, data.filename);
1098
- if (existsSync3(outputPath) && !cmdOpts.force) {
1668
+ if (existsSync4(outputPath) && !cmdOpts.force) {
1099
1669
  throw new Error(`Output file already exists: ${outputPath}. Use --force to overwrite.`);
1100
1670
  }
1101
1671
  const bytes = await downloadUrlToFile(data.url, outputPath);
@@ -1111,6 +1681,113 @@ function registerModelsCommands(parent) {
1111
1681
  const size = bytes == null ? "" : ` (${bytes} bytes)`;
1112
1682
  printSuccess(`Model downloaded to ${outputPath}${size}`);
1113
1683
  });
1684
+ models.command("setup-runtime").description("Install an isolated Python runtime for local model serving").option("--python <path>", "Python 3.10-3.12 executable to create the runtime").option("--cache-dir <path>", "Cache directory for the managed runtime").option("-f, --force", "Recreate the runtime if it already exists").option("--print-command", "Print the setup commands without running them").action(async (cmdOpts) => {
1685
+ const cacheDir = resolve2(cmdOpts.cacheDir || defaultCacheDir());
1686
+ const venvDir = runtimeDir(cacheDir);
1687
+ const venvPython = runtimePythonPath(cacheDir);
1688
+ const python = pickRuntimePython(cmdOpts.python);
1689
+ const deps = ["torch", "transformers", "accelerate", "safetensors"];
1690
+ const commands = setupRuntimeCommands(python, venvDir, deps);
1691
+ if (cmdOpts.printCommand) {
1692
+ const commandLines = commands.map((cmd) => cmd.map(quoteCommandPart).join(" "));
1693
+ if (isJsonMode()) {
1694
+ return printJson({
1695
+ python,
1696
+ runtime_dir: venvDir,
1697
+ runtime_python: venvPython,
1698
+ commands: commandLines
1699
+ });
1700
+ }
1701
+ printDetail([
1702
+ ["Python", python],
1703
+ ["Runtime", venvDir],
1704
+ ["Commands", commandLines.join("\n")]
1705
+ ]);
1706
+ return;
1707
+ }
1708
+ if (existsSync4(venvDir) && cmdOpts.force) {
1709
+ rmSync(venvDir, { recursive: true, force: true });
1710
+ }
1711
+ if (!existsSync4(venvPython)) {
1712
+ mkdirSync2(cacheDir, { recursive: true });
1713
+ execFileSync(commands[0][0], commands[0].slice(1), { stdio: "inherit" });
1714
+ }
1715
+ execFileSync(commands[1][0], commands[1].slice(1), { stdio: "inherit" });
1716
+ execFileSync(commands[2][0], commands[2].slice(1), { stdio: "inherit" });
1717
+ ensureServingRuntime(venvPython, cacheDir);
1718
+ if (isJsonMode()) {
1719
+ return printJson({
1720
+ runtime_dir: venvDir,
1721
+ runtime_python: venvPython,
1722
+ installed: true,
1723
+ dependencies: deps
1724
+ });
1725
+ }
1726
+ printSuccess(`Serving runtime ready at ${venvDir}`);
1727
+ console.log(`Use it with: tt models serve <model-id> --spec ${DEFAULT_SPEC_FILE}`);
1728
+ });
1729
+ models.command("serve").description("Serve a downloaded model with an OpenAI-compatible local API").argument("<target>", "Model ID/prefix, model directory, or .tar.gz artifact").option("--spec <path>", "Behavior spec JSON to apply as the default system prompt").option("--no-spec-prompt", "Do not auto-apply a behavior spec system prompt").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "8000").option("--python <path>", "Python executable").option("--cache-dir <path>", "Cache directory for downloaded/extracted models").option("--device <device>", "Inference device: auto, cpu, cuda, or mps", "auto").option("--force-download", "Re-download and re-extract model artifacts").option("--max-tokens <n>", "Default max completion tokens", "512").option("--temperature <n>", "Default sampling temperature", "0.7").option("--trust-remote-code", "Pass trust_remote_code=True to transformers").option("--print-command", "Print the underlying Python command without starting it").action(async (target, cmdOpts) => {
1730
+ const opts = parent.opts();
1731
+ const cacheDir = resolve2(cmdOpts.cacheDir || defaultCacheDir());
1732
+ mkdirSync2(cacheDir, { recursive: true });
1733
+ const python = resolveServePython(cacheDir, cmdOpts.python);
1734
+ if (!cmdOpts.printCommand) ensureServingRuntime(python, cacheDir);
1735
+ const serveTarget = await resolveServeTarget(
1736
+ target,
1737
+ opts,
1738
+ cacheDir,
1739
+ Boolean(cmdOpts.forceDownload)
1740
+ );
1741
+ const serverScript = writeReferenceServerScript(cacheDir);
1742
+ let specPath = null;
1743
+ let systemPrompt = "";
1744
+ if (cmdOpts.specPrompt !== false) {
1745
+ specPath = findSpecPath(cmdOpts.spec, serveTarget.modelPath);
1746
+ if (cmdOpts.spec && !specPath) {
1747
+ throw new Error(`Spec file not found: ${cmdOpts.spec}`);
1748
+ }
1749
+ if (specPath) {
1750
+ systemPrompt = loadSystemPromptFromSpec(specPath);
1751
+ }
1752
+ }
1753
+ const env = {
1754
+ TT_MODEL_PATH: serveTarget.modelPath,
1755
+ TT_MODEL_NAME: serveTarget.modelName,
1756
+ TT_HOST: cmdOpts.host,
1757
+ TT_PORT: String(cmdOpts.port),
1758
+ TT_MAX_TOKENS: String(cmdOpts.maxTokens),
1759
+ TT_TEMPERATURE: String(cmdOpts.temperature),
1760
+ TT_TRUST_REMOTE_CODE: cmdOpts.trustRemoteCode ? "true" : "false",
1761
+ TT_DEVICE: String(cmdOpts.device)
1762
+ };
1763
+ if (systemPrompt) env.TT_SYSTEM_PROMPT = systemPrompt;
1764
+ const args = [serverScript];
1765
+ if (cmdOpts.printCommand) return printServeCommand(python, args, env);
1766
+ if (!isJsonMode()) {
1767
+ printSuccess(`Serving ${serveTarget.modelName} from ${serveTarget.modelPath}`);
1768
+ if (systemPrompt && specPath) {
1769
+ printSuccess(`Behavior spec prompt loaded from ${specPath}`);
1770
+ } else if (cmdOpts.specPrompt !== false) {
1771
+ printWarning(
1772
+ `No ${DEFAULT_SPEC_FILE} found. Pass --spec <path> to preserve the behavior prompt.`
1773
+ );
1774
+ }
1775
+ console.log(`OpenAI-compatible endpoint: http://${cmdOpts.host}:${cmdOpts.port}/v1/chat/completions`);
1776
+ console.log(`Health check: http://${cmdOpts.host}:${cmdOpts.port}/health`);
1777
+ console.log(`Python runtime: ${python}`);
1778
+ }
1779
+ await new Promise((resolvePromise, reject) => {
1780
+ const child = spawn(python, args, {
1781
+ stdio: "inherit",
1782
+ env: { ...process.env, ...env }
1783
+ });
1784
+ child.on("error", reject);
1785
+ child.on("exit", (code, signal) => {
1786
+ if (code === 0) return resolvePromise();
1787
+ reject(new Error(`Model server exited with ${signal ?? `code ${code}`}`));
1788
+ });
1789
+ });
1790
+ });
1114
1791
  models.command("delete").description("Delete a model").argument("<id>", "Model ID (full UUID or 4+ char prefix)").action(async (id) => {
1115
1792
  const opts = parent.opts();
1116
1793
  const fullId = await resolveModelId(id, opts);
@@ -1274,53 +1951,6 @@ function registerTopupCommands(parent) {
1274
1951
  });
1275
1952
  }
1276
1953
 
1277
- // src/commands/init.ts
1278
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1279
- import { resolve } from "path";
1280
- var DEFAULT_SPEC_FILE = "tunedtensor.json";
1281
- var SCAFFOLD = {
1282
- name: "My Agent",
1283
- description: "",
1284
- base_model: "Qwen/Qwen3.5-2B",
1285
- system_prompt: "You are a helpful assistant.",
1286
- guidelines: [],
1287
- constraints: [],
1288
- examples: [
1289
- { input: "Hello", output: "Hi! How can I help you today?" }
1290
- ],
1291
- eval_cases: []
1292
- };
1293
- function registerInitCommand(parent) {
1294
- parent.command("init").description("Create a local behaviour spec file").option("-n, --name <name>", "Spec name").option("--model <model>", "Base model ID").option("-f, --file <path>", "Output file path", DEFAULT_SPEC_FILE).action(async (cmdOpts) => {
1295
- const filePath = resolve(cmdOpts.file);
1296
- if (existsSync4(filePath)) {
1297
- printWarning(`${cmdOpts.file} already exists. Use tt eval to validate it or edit it directly.`);
1298
- return;
1299
- }
1300
- const spec = { ...SCAFFOLD };
1301
- if (cmdOpts.name) spec.name = cmdOpts.name;
1302
- if (cmdOpts.model) spec.base_model = canonicalizeBaseModel(cmdOpts.model);
1303
- writeFileSync2(filePath, JSON.stringify(spec, null, 2) + "\n");
1304
- printSuccess(`Created ${cmdOpts.file}`);
1305
- console.log("\nNext steps:");
1306
- console.log(" 1. Edit the spec: system_prompt, guidelines, examples");
1307
- console.log(" 2. Validate spec: tt eval");
1308
- console.log(" 3. Push to remote: tt push");
1309
- });
1310
- }
1311
- function loadSpec(filePath) {
1312
- const resolved = resolve(filePath || DEFAULT_SPEC_FILE);
1313
- if (!existsSync4(resolved)) {
1314
- printError(
1315
- `Spec file not found: ${filePath || DEFAULT_SPEC_FILE}
1316
- Run \`tt init\` to create one.`
1317
- );
1318
- process.exit(1);
1319
- }
1320
- const raw = JSON.parse(readFileSync5(resolved, "utf-8"));
1321
- return raw;
1322
- }
1323
-
1324
1954
  // src/commands/eval.ts
1325
1955
  import chalk5 from "chalk";
1326
1956
 
@@ -1432,12 +2062,12 @@ function printValidation(checks) {
1432
2062
  }
1433
2063
 
1434
2064
  // src/commands/push.ts
1435
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
1436
- import { resolve as resolve2 } from "path";
2065
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
2066
+ import { resolve as resolve3 } from "path";
1437
2067
  function registerPushCommand(parent) {
1438
2068
  parent.command("push").description("Push local spec to the Tuned Tensor API").option("-f, --file <path>", "Spec file path", DEFAULT_SPEC_FILE).action(async (cmdOpts) => {
1439
2069
  const opts = parent.opts();
1440
- const filePath = resolve2(cmdOpts.file);
2070
+ const filePath = resolve3(cmdOpts.file);
1441
2071
  const spec = loadSpec(cmdOpts.file);
1442
2072
  const { id, eval_cases, ...rawBody } = spec;
1443
2073
  const body = canonicalizeSpecBaseModel(rawBody);
@@ -1448,9 +2078,9 @@ function registerPushCommand(parent) {
1448
2078
  } else {
1449
2079
  const res = await post("/behavior-specs", body, opts);
1450
2080
  data = res.data;
1451
- const raw = JSON.parse(readFileSync6(filePath, "utf-8"));
2081
+ const raw = JSON.parse(readFileSync7(filePath, "utf-8"));
1452
2082
  raw.id = data.id;
1453
- writeFileSync3(filePath, JSON.stringify(raw, null, 2) + "\n");
2083
+ writeFileSync4(filePath, JSON.stringify(raw, null, 2) + "\n");
1454
2084
  }
1455
2085
  if (isJsonMode()) return printJson(data);
1456
2086
  printSuccess(
@@ -1461,7 +2091,7 @@ function registerPushCommand(parent) {
1461
2091
 
1462
2092
  // src/index.ts
1463
2093
  var program = new Command();
1464
- program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.11").option("-k, --api-key <key>", "API key (overrides stored key)").option(
2094
+ program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.13").option("-k, --api-key <key>", "API key (overrides stored key)").option(
1465
2095
  "-u, --base-url <url>",
1466
2096
  "API base URL (default: https://tunedtensor.com)"
1467
2097
  ).option("--json", "Output raw JSON").option("--no-color", "Disable colors").hook("preAction", (_thisCommand, actionCommand) => {