@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/README.md +19 -1
- package/dist/index.js +691 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
1008
|
-
import {
|
|
1009
|
-
import {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
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 (
|
|
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
|
|
1436
|
-
import { resolve as
|
|
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 =
|
|
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(
|
|
2081
|
+
const raw = JSON.parse(readFileSync7(filePath, "utf-8"));
|
|
1452
2082
|
raw.id = data.id;
|
|
1453
|
-
|
|
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.
|
|
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) => {
|