@tuned-tensor/cli 0.4.11 → 0.4.12

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