@tuned-tensor/cli 0.4.19 → 0.4.21

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
@@ -122,6 +122,9 @@ function post(path, body, opts) {
122
122
  function put(path, body, opts) {
123
123
  return request("PUT", path, { body, opts });
124
124
  }
125
+ function patch(path, body, opts) {
126
+ return request("PATCH", path, { body, opts });
127
+ }
125
128
  function del(path, opts) {
126
129
  return request("DELETE", path, { opts });
127
130
  }
@@ -297,10 +300,10 @@ var ResolveError = class extends Error {
297
300
  function isFullUuid(value) {
298
301
  return UUID_RE.test(value);
299
302
  }
300
- async function resolvePrefix(prefix, kind, listPath, describe, opts) {
303
+ async function resolvePrefix(prefix, kind, listPath, describe, opts, listCmdOverride) {
301
304
  if (isFullUuid(prefix)) return prefix;
302
305
  const noun = kind;
303
- const listCmd = `tt ${kind}s list --json`;
306
+ const listCmd = listCmdOverride ?? `tt ${kind}s list --json`;
304
307
  if (prefix.length < MIN_PREFIX_LEN) {
305
308
  throw new ResolveError(
306
309
  `${capitalize(noun)} ID prefix "${prefix}" is too short \u2014 provide at least ${MIN_PREFIX_LEN} characters or the full UUID.`
@@ -368,12 +371,23 @@ function resolveModelId(prefix, opts) {
368
371
  opts
369
372
  );
370
373
  }
374
+ function resolveLabelingJobId(prefix, opts) {
375
+ return resolvePrefix(
376
+ prefix,
377
+ "labeling job",
378
+ "/labeling-jobs",
379
+ (j) => `${j.id}${j.name ? ` (${j.name})` : ""}`,
380
+ opts,
381
+ "tt label list --json"
382
+ );
383
+ }
371
384
 
372
385
  // src/base-models.ts
373
386
  var SUPPORTED_BASE_MODELS = [
374
387
  "google/gemma-4-E2B-it",
375
388
  "google/gemma-4-E4B-it",
376
389
  "Qwen/Qwen3.5-2B",
390
+ "Qwen/Qwen3-VL-2B-Instruct",
377
391
  "Qwen/Qwen3.5-4B",
378
392
  "meta-llama/Llama-3.2-3B-Instruct",
379
393
  "microsoft/Phi-4-mini-instruct",
@@ -394,6 +408,9 @@ for (const [alias, model] of [
394
408
  ["qwen/qwen3.5-2b", "Qwen/Qwen3.5-2B"],
395
409
  ["Qwen/Qwen3.5-2B-Base", "Qwen/Qwen3.5-2B"],
396
410
  ["qwen/qwen3.5-2b-base", "Qwen/Qwen3.5-2B"],
411
+ ["qwen/qwen3-vl-2b-instruct", "Qwen/Qwen3-VL-2B-Instruct"],
412
+ ["Qwen/Qwen3-VL-2B", "Qwen/Qwen3-VL-2B-Instruct"],
413
+ ["qwen/qwen3-vl-2b", "Qwen/Qwen3-VL-2B-Instruct"],
397
414
  ["qwen/qwen3.5-4b", "Qwen/Qwen3.5-4B"],
398
415
  ["Qwen/Qwen3.5-4B-Base", "Qwen/Qwen3.5-4B"],
399
416
  ["qwen/qwen3.5-4b-base", "Qwen/Qwen3.5-4B"],
@@ -946,6 +963,7 @@ Eval Results (${data._evals.length}):`);
946
963
 
947
964
  // src/commands/datasets.ts
948
965
  import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
966
+ var DOCUMENT_OCR_MIME_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
949
967
  function formatBytes(bytes) {
950
968
  if (bytes < 1024) return `${bytes} B`;
951
969
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -999,10 +1017,85 @@ function findControlChar(value) {
999
1017
  function formatCodepoint(code) {
1000
1018
  return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`;
1001
1019
  }
1002
- function validateDatasetFile(file) {
1020
+ function detectRowFormat(record) {
1021
+ const input = record.input;
1022
+ if (input && typeof input === "object" && typeof input.prompt === "string" && Array.isArray(input.assets) && input.assets.length > 0 && typeof record.output === "string") {
1023
+ return "document_ocr_jsonl";
1024
+ }
1025
+ if (typeof record.input === "string" || typeof record.output === "string" || !("input" in record) || !("output" in record)) {
1026
+ return "jsonl";
1027
+ }
1028
+ return null;
1029
+ }
1030
+ function validateDocumentOcrRow(record, rowNumber, errors) {
1031
+ const input = record.input;
1032
+ const assets = input.assets;
1033
+ if (typeof input.prompt !== "string" || input.prompt.trim().length === 0) {
1034
+ errors.push(`Row ${rowNumber}: OCR input.prompt must be a non-empty string`);
1035
+ } else {
1036
+ const hit = findControlChar(input.prompt);
1037
+ if (hit) {
1038
+ errors.push(
1039
+ `Row ${rowNumber}: OCR input.prompt contains invisible control character ${formatCodepoint(hit.codepoint)} (${hit.name}) at offset ${hit.offset} \u2014 strip or escape before uploading; some training paths split on it`
1040
+ );
1041
+ }
1042
+ }
1043
+ if (typeof record.output !== "string" || record.output.trim().length === 0) {
1044
+ errors.push(`Row ${rowNumber}: OCR output must be a non-empty string`);
1045
+ } else {
1046
+ const hit = findControlChar(record.output);
1047
+ if (hit) {
1048
+ errors.push(
1049
+ `Row ${rowNumber}: OCR output contains invisible control character ${formatCodepoint(hit.codepoint)} (${hit.name}) at offset ${hit.offset} \u2014 strip or escape before uploading; some training paths split on it`
1050
+ );
1051
+ }
1052
+ }
1053
+ if (assets.length > 8) {
1054
+ errors.push(`Row ${rowNumber}: OCR rows support at most 8 image assets`);
1055
+ }
1056
+ for (const [assetIndex, asset] of assets.entries()) {
1057
+ if (!asset || typeof asset !== "object" || Array.isArray(asset)) {
1058
+ errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} must be an object`);
1059
+ continue;
1060
+ }
1061
+ const a = asset;
1062
+ if (a.type !== void 0 && a.type !== "image") {
1063
+ errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} type must be "image"`);
1064
+ }
1065
+ if (a.mime_type !== void 0 && (typeof a.mime_type !== "string" || !DOCUMENT_OCR_MIME_TYPES.has(a.mime_type))) {
1066
+ errors.push(
1067
+ `Row ${rowNumber}: asset ${assetIndex + 1} mime_type must be one of image/png, image/jpeg, image/webp`
1068
+ );
1069
+ }
1070
+ if (a.page !== void 0 && (typeof a.page !== "number" || !Number.isInteger(a.page) || a.page < 1)) {
1071
+ errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} page must be a positive integer`);
1072
+ }
1073
+ const hasReference = typeof a.data_uri === "string" && a.data_uri.trim().length > 0 || typeof a.uri === "string" && a.uri.trim().length > 0 || typeof a.path === "string" && a.path.trim().length > 0;
1074
+ if (!hasReference) {
1075
+ errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} must include data_uri, uri, or path`);
1076
+ }
1077
+ if (typeof a.data_uri === "string") {
1078
+ const match = /^data:(image\/(?:png|jpeg|webp));base64,[A-Za-z0-9+/=\s]+$/.exec(a.data_uri);
1079
+ if (!match) {
1080
+ errors.push(
1081
+ `Row ${rowNumber}: asset ${assetIndex + 1} data_uri must be a base64 image/png, image/jpeg, or image/webp data URI`
1082
+ );
1083
+ } else if (typeof a.mime_type === "string" && a.mime_type !== match[1]) {
1084
+ errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} mime_type does not match data_uri media type`);
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ function parseDatasetFormat(value) {
1090
+ if (!value) return void 0;
1091
+ if (value === "jsonl" || value === "document_ocr_jsonl") return value;
1092
+ throw new Error("--format must be one of: jsonl, document_ocr_jsonl");
1093
+ }
1094
+ function validateDatasetFile(file, requestedFormat) {
1003
1095
  const lines = readFileSync4(file, "utf8").split(/\r?\n/);
1004
1096
  const errors = [];
1005
1097
  let rowCount = 0;
1098
+ let detectedFormat = null;
1006
1099
  for (const [index, rawLine] of lines.entries()) {
1007
1100
  const line = rawLine.trim();
1008
1101
  if (!line) continue;
@@ -1026,6 +1119,26 @@ function validateDatasetFile(file) {
1026
1119
  );
1027
1120
  continue;
1028
1121
  }
1122
+ const rowFormat = detectRowFormat(record);
1123
+ if (!rowFormat) {
1124
+ errors.push(
1125
+ `Row ${rowNumber}: expected text {"input": string, "output": string} or OCR {"input":{"prompt": string, "assets": [...]}, "output": string}`
1126
+ );
1127
+ continue;
1128
+ }
1129
+ detectedFormat ??= rowFormat;
1130
+ if (requestedFormat && rowFormat !== requestedFormat) {
1131
+ errors.push(`Row ${rowNumber}: expected ${requestedFormat} row, found ${rowFormat}`);
1132
+ continue;
1133
+ }
1134
+ if (detectedFormat !== rowFormat) {
1135
+ errors.push(`Row ${rowNumber}: cannot mix ${detectedFormat} and ${rowFormat} rows in one dataset`);
1136
+ continue;
1137
+ }
1138
+ if (rowFormat === "document_ocr_jsonl") {
1139
+ validateDocumentOcrRow(record, rowNumber, errors);
1140
+ continue;
1141
+ }
1029
1142
  if (typeof record.input !== "string") {
1030
1143
  errors.push(`Row ${rowNumber}: missing string "input" field`);
1031
1144
  }
@@ -1051,10 +1164,11 @@ function validateDatasetFile(file) {
1051
1164
  const suffix = errors.length > 5 ? `
1052
1165
  ...and ${errors.length - 5} more error(s)` : "";
1053
1166
  throw new Error(
1054
- `Invalid dataset format. Each JSONL row must be {"input": "...", "output": "..."}.
1167
+ `Invalid dataset format. Each JSONL row must be a valid text or document OCR example.
1055
1168
  ${preview}${suffix}`
1056
1169
  );
1057
1170
  }
1171
+ return detectedFormat ?? requestedFormat ?? "jsonl";
1058
1172
  }
1059
1173
  function registerDatasetsCommands(parent) {
1060
1174
  const datasets = parent.command("datasets").description("Manage datasets");
@@ -1102,13 +1216,13 @@ function registerDatasetsCommands(parent) {
1102
1216
  }
1103
1217
  }
1104
1218
  });
1105
- datasets.command("upload").description("Upload a JSONL dataset file").argument("<file>", "Path to JSONL file").option("-n, --name <name>", "Dataset name (defaults to filename)").option("-d, --description <desc>", "Dataset description").action(async (file, cmdOpts) => {
1219
+ datasets.command("upload").description("Upload a JSONL dataset file").argument("<file>", "Path to JSONL file").option("-n, --name <name>", "Dataset name (defaults to filename)").option("-d, --description <desc>", "Dataset description").option("--format <format>", "Dataset format: jsonl or document_ocr_jsonl").action(async (file, cmdOpts) => {
1106
1220
  const opts = parent.opts();
1107
1221
  if (!existsSync2(file)) {
1108
1222
  printError(`File not found: ${file}`);
1109
1223
  process.exit(1);
1110
1224
  }
1111
- validateDatasetFile(file);
1225
+ const format = validateDatasetFile(file, parseDatasetFormat(cmdOpts.format));
1112
1226
  const name = cmdOpts.name || file.split("/").pop().replace(/\.jsonl?$/, "");
1113
1227
  const fileBytes = readFileSync4(file);
1114
1228
  const uploadUrl = await post(
@@ -1118,7 +1232,8 @@ function registerDatasetsCommands(parent) {
1118
1232
  description: cmdOpts.description ?? null,
1119
1233
  filename: file.split("/").pop(),
1120
1234
  size: statSync2(file).size,
1121
- contentType: "application/jsonl"
1235
+ contentType: "application/jsonl",
1236
+ format
1122
1237
  },
1123
1238
  opts
1124
1239
  );
@@ -1153,27 +1268,425 @@ function registerDatasetsCommands(parent) {
1153
1268
  });
1154
1269
  }
1155
1270
 
1271
+ // src/commands/label.ts
1272
+ import { existsSync as existsSync3, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
1273
+ function formatCents2(cents) {
1274
+ return `$${(cents / 100).toFixed(2)}`;
1275
+ }
1276
+ function sleep(ms) {
1277
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
1278
+ }
1279
+ function parseCsvHeader(line) {
1280
+ const fields = [];
1281
+ let current = "";
1282
+ let inQuotes = false;
1283
+ for (let i = 0; i < line.length; i++) {
1284
+ const ch = line[i];
1285
+ if (inQuotes) {
1286
+ if (ch === '"') {
1287
+ if (line[i + 1] === '"') {
1288
+ current += '"';
1289
+ i++;
1290
+ } else {
1291
+ inQuotes = false;
1292
+ }
1293
+ } else {
1294
+ current += ch;
1295
+ }
1296
+ } else if (ch === '"') {
1297
+ inQuotes = true;
1298
+ } else if (ch === ",") {
1299
+ fields.push(current);
1300
+ current = "";
1301
+ } else {
1302
+ current += ch;
1303
+ }
1304
+ }
1305
+ fields.push(current);
1306
+ return fields.map((f) => f.trim());
1307
+ }
1308
+ function validateUnlabeledFile(file, inputColumn) {
1309
+ const lower = file.toLowerCase();
1310
+ if (lower.endsWith(".csv")) {
1311
+ if (!inputColumn) {
1312
+ throw new Error(
1313
+ "CSV uploads need --input-column <name> to say which column holds the input text."
1314
+ );
1315
+ }
1316
+ const firstLine = readFileSync5(file, "utf8").split(/\r?\n/, 1)[0] ?? "";
1317
+ const columns = parseCsvHeader(firstLine);
1318
+ if (!columns.includes(inputColumn)) {
1319
+ throw new Error(
1320
+ `Column "${inputColumn}" not found in CSV header. Available columns: ${columns.join(", ")}`
1321
+ );
1322
+ }
1323
+ return { format: "csv" };
1324
+ }
1325
+ if (!lower.endsWith(".jsonl") && !lower.endsWith(".json")) {
1326
+ throw new Error("Labeling source file must be .jsonl or .csv");
1327
+ }
1328
+ const lines = readFileSync5(file, "utf8").split(/\r?\n/);
1329
+ const errors = [];
1330
+ let rowCount = 0;
1331
+ for (const [index, rawLine] of lines.entries()) {
1332
+ const line = rawLine.trim();
1333
+ if (!line) continue;
1334
+ rowCount += 1;
1335
+ let row;
1336
+ try {
1337
+ row = JSON.parse(line);
1338
+ } catch {
1339
+ errors.push(`Row ${index + 1}: invalid JSON`);
1340
+ continue;
1341
+ }
1342
+ if (!row || typeof row !== "object" || Array.isArray(row) || typeof row.input !== "string") {
1343
+ errors.push(`Row ${index + 1}: expected an object with a string "input" field`);
1344
+ }
1345
+ }
1346
+ if (rowCount === 0) {
1347
+ errors.push("File contains no JSONL rows");
1348
+ }
1349
+ if (errors.length > 0) {
1350
+ const preview = errors.slice(0, 5).join("\n");
1351
+ const suffix = errors.length > 5 ? `
1352
+ ...and ${errors.length - 5} more error(s)` : "";
1353
+ throw new Error(
1354
+ `Invalid labeling source. Each JSONL row must be {"input": "..."}.
1355
+ ${preview}${suffix}`
1356
+ );
1357
+ }
1358
+ return { format: "jsonl" };
1359
+ }
1360
+ function printJobDetail(job, counts) {
1361
+ printDetail([
1362
+ ["ID", job.id],
1363
+ ["Name", job.name],
1364
+ ["Status", formatStatus(job.status)],
1365
+ ["Spec", shortId(job.behavior_spec_id)],
1366
+ ["Teacher", job.teacher_model],
1367
+ ["Rows", String(job.row_count)],
1368
+ ["Labeled", String(job.labeled_count)],
1369
+ ["Failed", String(job.failed_count)],
1370
+ [
1371
+ "Cost",
1372
+ job.actual_cost_cents !== null ? formatCents2(job.actual_cost_cents) : `~${formatCents2(job.est_cost_cents)} (estimated)`
1373
+ ],
1374
+ ["Dataset", job.promoted_dataset_id ? shortId(job.promoted_dataset_id) : void 0],
1375
+ ["Error", job.error ?? void 0],
1376
+ ["Created", formatDate(job.created_at)]
1377
+ ]);
1378
+ if (counts) {
1379
+ console.log(
1380
+ `
1381
+ Review: ${counts.labeled} awaiting \xB7 ${counts.accepted} accepted \xB7 ${counts.edited} edited \xB7 ${counts.rejected} rejected \xB7 ${counts.failed} failed`
1382
+ );
1383
+ }
1384
+ }
1385
+ async function watchJob(jobId, opts) {
1386
+ for (; ; ) {
1387
+ const { data } = await get(
1388
+ `/labeling-jobs/${jobId}`,
1389
+ void 0,
1390
+ opts
1391
+ );
1392
+ if (!isJsonMode()) {
1393
+ if (data.status === "preparing") {
1394
+ process.stdout.write("\rPreparing - parsing the source file... ");
1395
+ } else {
1396
+ const done = data.counts.total - data.counts.pending;
1397
+ process.stdout.write(`\rLabeling ${done}/${data.counts.total} rows `);
1398
+ }
1399
+ }
1400
+ if (data.status !== "preparing" && data.status !== "labeling") {
1401
+ if (!isJsonMode()) process.stdout.write("\n");
1402
+ return data;
1403
+ }
1404
+ await sleep(5e3);
1405
+ }
1406
+ }
1407
+ async function resolveRowIds(jobId, indexes, opts) {
1408
+ const PER_PAGE = 100;
1409
+ const byPage = /* @__PURE__ */ new Map();
1410
+ for (const index of indexes) {
1411
+ const page = Math.floor(index / PER_PAGE) + 1;
1412
+ byPage.set(page, [...byPage.get(page) ?? [], index]);
1413
+ }
1414
+ const found = /* @__PURE__ */ new Map();
1415
+ for (const [page, wanted] of byPage) {
1416
+ const { data } = await get(
1417
+ `/labeling-jobs/${jobId}/rows`,
1418
+ { page, per_page: PER_PAGE },
1419
+ opts
1420
+ );
1421
+ for (const row of data) {
1422
+ if (wanted.includes(row.row_index)) {
1423
+ found.set(row.row_index, row.id);
1424
+ }
1425
+ }
1426
+ }
1427
+ const missing = indexes.filter((i) => !found.has(i));
1428
+ if (missing.length > 0) {
1429
+ throw new Error(`Row index(es) not found: ${missing.join(", ")}`);
1430
+ }
1431
+ return found;
1432
+ }
1433
+ function parseIndexList(value) {
1434
+ const indexes = value.split(",").map((part) => Number.parseInt(part.trim(), 10));
1435
+ if (indexes.some((n) => Number.isNaN(n) || n < 0)) {
1436
+ throw new Error(`Invalid row index list: "${value}" (expected e.g. "0,3,12")`);
1437
+ }
1438
+ return indexes;
1439
+ }
1440
+ function registerLabelCommands(parent) {
1441
+ const label = parent.command("label").description("Teacher-label real data into training datasets");
1442
+ label.command("upload").description("Upload unlabeled inputs (.jsonl or .csv) and start a labeling job").argument("<file>", 'Path to JSONL ({"input": ...} per line) or CSV file').requiredOption("-s, --spec <id>", "Behaviour spec the teacher labels under").option("-c, --input-column <name>", "CSV column that holds the input text").option("-n, --name <name>", "Job name (defaults to filename)").option("--watch", "Block and show progress until labeling finishes").action(async (file, cmdOpts) => {
1443
+ const opts = parent.opts();
1444
+ if (!existsSync3(file)) {
1445
+ printError(`File not found: ${file}`);
1446
+ process.exit(1);
1447
+ }
1448
+ const { format } = validateUnlabeledFile(file, cmdOpts.inputColumn);
1449
+ const specId = await resolveSpecId(cmdOpts.spec, opts);
1450
+ const name = cmdOpts.name || file.split("/").pop().replace(/\.(jsonl|json|csv)$/, "");
1451
+ const fileBytes = readFileSync5(file);
1452
+ const contentType = format === "csv" ? "text/csv" : "application/jsonl";
1453
+ const { data: uploadUrl } = await post(
1454
+ "/labeling-jobs/upload-url",
1455
+ {
1456
+ filename: file.split("/").pop(),
1457
+ size: statSync3(file).size,
1458
+ contentType
1459
+ },
1460
+ opts
1461
+ );
1462
+ const uploadRes = await fetch(uploadUrl.upload_url, {
1463
+ method: uploadUrl.method,
1464
+ headers: uploadUrl.headers ?? { "Content-Type": contentType },
1465
+ body: new Blob([fileBytes], { type: contentType })
1466
+ });
1467
+ if (!uploadRes.ok) {
1468
+ throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
1469
+ }
1470
+ const { data: job } = await post(
1471
+ "/labeling-jobs",
1472
+ {
1473
+ path: uploadUrl.path,
1474
+ name,
1475
+ behavior_spec_id: specId,
1476
+ source_format: format,
1477
+ input_column: cmdOpts.inputColumn ?? null
1478
+ },
1479
+ opts
1480
+ );
1481
+ if (!cmdOpts.watch) {
1482
+ if (isJsonMode()) return printJson(job);
1483
+ printSuccess(
1484
+ `Labeling started: ${job.name} (${shortId(job.id)}) \u2014 est. ${formatCents2(job.est_cost_cents)}. Runs in the cloud; no need to stay connected.`
1485
+ );
1486
+ console.log(`Follow progress with \`tt label watch ${shortId(job.id)}\`.`);
1487
+ return;
1488
+ }
1489
+ const finished = await watchJob(job.id, opts);
1490
+ if (isJsonMode()) return printJson(finished);
1491
+ printSuccess(
1492
+ `Labeling ${finished.status === "awaiting_review" ? "complete" : finished.status}: ${finished.labeled_count}/${finished.row_count} rows labeled.`
1493
+ );
1494
+ console.log(
1495
+ `Review with \`tt label rows ${shortId(job.id)} --status labeled\`, then \`tt label promote ${shortId(job.id)} --name <dataset-name>\`.`
1496
+ );
1497
+ });
1498
+ label.command("watch").description("Watch a labeling job until it is ready for review").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").action(async (id) => {
1499
+ const opts = parent.opts();
1500
+ const jobId = await resolveLabelingJobId(id, opts);
1501
+ const job = await watchJob(jobId, opts);
1502
+ if (isJsonMode()) return printJson(job);
1503
+ if (job.status === "awaiting_review") {
1504
+ printSuccess(
1505
+ `Labeling complete: ${job.labeled_count}/${job.row_count} rows labeled, cost ${job.actual_cost_cents !== null ? formatCents2(job.actual_cost_cents) : "n/a"}.`
1506
+ );
1507
+ } else {
1508
+ printError(`Labeling ended with status: ${job.status}${job.error ? ` \u2014 ${job.error}` : ""}`);
1509
+ process.exitCode = 1;
1510
+ }
1511
+ });
1512
+ label.command("list").description("List labeling jobs").option("-p, --page <n>", "Page number", "1").option("--per-page <n>", "Results per page", "20").action(async (cmdOpts) => {
1513
+ const opts = parent.opts();
1514
+ const { data, meta } = await get(
1515
+ "/labeling-jobs",
1516
+ { page: cmdOpts.page, per_page: cmdOpts.perPage },
1517
+ opts
1518
+ );
1519
+ if (isJsonMode()) return printJson({ data, meta });
1520
+ printTable(
1521
+ ["ID", "Name", "Status", "Progress", "Cost", "Created"],
1522
+ data.map((j) => [
1523
+ shortId(j.id),
1524
+ truncate(j.name, 30),
1525
+ formatStatus(j.status),
1526
+ `${j.labeled_count}/${j.row_count}`,
1527
+ j.actual_cost_cents !== null ? formatCents2(j.actual_cost_cents) : `~${formatCents2(j.est_cost_cents)}`,
1528
+ formatDate(j.created_at)
1529
+ ]),
1530
+ meta
1531
+ );
1532
+ });
1533
+ label.command("status").description("Show labeling job details and review progress").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").action(async (id) => {
1534
+ const opts = parent.opts();
1535
+ const jobId = await resolveLabelingJobId(id, opts);
1536
+ const { data } = await get(
1537
+ `/labeling-jobs/${jobId}`,
1538
+ void 0,
1539
+ opts
1540
+ );
1541
+ if (isJsonMode()) return printJson(data);
1542
+ printJobDetail(data, data.counts);
1543
+ });
1544
+ label.command("rows").description("List rows in a labeling job").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").option(
1545
+ "--status <status>",
1546
+ "Filter by row status (pending|labeled|accepted|edited|rejected|failed)"
1547
+ ).option("-p, --page <n>", "Page number", "1").option("--per-page <n>", "Results per page", "50").action(async (id, cmdOpts) => {
1548
+ const opts = parent.opts();
1549
+ const jobId = await resolveLabelingJobId(id, opts);
1550
+ const query = {
1551
+ page: cmdOpts.page,
1552
+ per_page: cmdOpts.perPage
1553
+ };
1554
+ if (cmdOpts.status) query.status = cmdOpts.status;
1555
+ const { data, meta } = await get(
1556
+ `/labeling-jobs/${jobId}/rows`,
1557
+ query,
1558
+ opts
1559
+ );
1560
+ if (isJsonMode()) return printJson({ data, meta });
1561
+ printTable(
1562
+ ["Row", "Status", "Input", "Output"],
1563
+ data.map((row) => [
1564
+ String(row.row_index),
1565
+ formatStatus(row.status),
1566
+ truncate(row.input.replace(/\s+/g, " "), 40),
1567
+ truncate(
1568
+ (row.final_output ?? row.teacher_output ?? "\u2014").replace(/\s+/g, " "),
1569
+ 40
1570
+ )
1571
+ ]),
1572
+ meta
1573
+ );
1574
+ });
1575
+ label.command("accept").description("Accept teacher-labeled rows").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").option("--all", "Accept all unreviewed labeled rows").option("--rows <indexes>", "Comma-separated row indexes (e.g. 0,3,12)").action(async (id, cmdOpts) => {
1576
+ const opts = parent.opts();
1577
+ if (!cmdOpts.all && !cmdOpts.rows) {
1578
+ printError("Provide --all or --rows <indexes>.");
1579
+ process.exit(1);
1580
+ }
1581
+ const jobId = await resolveLabelingJobId(id, opts);
1582
+ if (cmdOpts.all) {
1583
+ const { data } = await post(
1584
+ `/labeling-jobs/${jobId}/rows/bulk`,
1585
+ { action: "accept", all_labeled: true },
1586
+ opts
1587
+ );
1588
+ if (isJsonMode()) return printJson(data);
1589
+ return printSuccess(`Accepted ${data.accepted} rows.`);
1590
+ }
1591
+ const indexes = parseIndexList(cmdOpts.rows);
1592
+ const rowIds = await resolveRowIds(jobId, indexes, opts);
1593
+ for (const index of indexes) {
1594
+ await patch(
1595
+ `/labeling-jobs/${jobId}/rows/${rowIds.get(index)}`,
1596
+ { action: "accept" },
1597
+ opts
1598
+ );
1599
+ }
1600
+ if (isJsonMode()) return printJson({ accepted: indexes.length });
1601
+ printSuccess(`Accepted ${indexes.length} row(s).`);
1602
+ });
1603
+ label.command("reject").description("Reject rows so they are excluded from promotion").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").requiredOption("--rows <indexes>", "Comma-separated row indexes (e.g. 0,3,12)").action(async (id, cmdOpts) => {
1604
+ const opts = parent.opts();
1605
+ const jobId = await resolveLabelingJobId(id, opts);
1606
+ const indexes = parseIndexList(cmdOpts.rows);
1607
+ const rowIds = await resolveRowIds(jobId, indexes, opts);
1608
+ for (const index of indexes) {
1609
+ await patch(
1610
+ `/labeling-jobs/${jobId}/rows/${rowIds.get(index)}`,
1611
+ { action: "reject" },
1612
+ opts
1613
+ );
1614
+ }
1615
+ if (isJsonMode()) return printJson({ rejected: indexes.length });
1616
+ printSuccess(`Rejected ${indexes.length} row(s).`);
1617
+ });
1618
+ label.command("edit").description("Replace a row's output with your own").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").requiredOption("--row <index>", "Row index to edit").option("-o, --output <text>", "Replacement output text").option("-f, --file <path>", "Read replacement output from a file").action(async (id, cmdOpts) => {
1619
+ const opts = parent.opts();
1620
+ if (!cmdOpts.output && !cmdOpts.file) {
1621
+ printError("Provide --output <text> or --file <path>.");
1622
+ process.exit(1);
1623
+ }
1624
+ const output = cmdOpts.output ?? readFileSync5(cmdOpts.file, "utf8");
1625
+ const jobId = await resolveLabelingJobId(id, opts);
1626
+ const indexes = parseIndexList(cmdOpts.row);
1627
+ const rowIds = await resolveRowIds(jobId, indexes, opts);
1628
+ await patch(
1629
+ `/labeling-jobs/${jobId}/rows/${rowIds.get(indexes[0])}`,
1630
+ { action: "edit", output },
1631
+ opts
1632
+ );
1633
+ if (isJsonMode()) return printJson({ edited: indexes[0] });
1634
+ printSuccess(`Row ${indexes[0]} edited.`);
1635
+ });
1636
+ label.command("promote").description("Promote reviewed rows into a validated dataset").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").requiredOption("-n, --name <name>", "Name for the new dataset").option("-d, --description <desc>", "Dataset description").option("--include-unreviewed", "Also include unreviewed labeled rows").action(async (id, cmdOpts) => {
1637
+ const opts = parent.opts();
1638
+ const jobId = await resolveLabelingJobId(id, opts);
1639
+ const { data } = await post(
1640
+ `/labeling-jobs/${jobId}/promote`,
1641
+ {
1642
+ name: cmdOpts.name,
1643
+ description: cmdOpts.description ?? null,
1644
+ include_unreviewed_labeled: Boolean(cmdOpts.includeUnreviewed)
1645
+ },
1646
+ opts
1647
+ );
1648
+ if (isJsonMode()) return printJson(data);
1649
+ printSuccess(
1650
+ `Dataset created: ${data.name} (${shortId(data.id)}) \u2014 ${data.row_count} rows.`
1651
+ );
1652
+ console.log(
1653
+ `Start a run with \`tt runs start <spec-id> --dataset ${shortId(data.id)}\`.`
1654
+ );
1655
+ });
1656
+ label.command("cancel").description("Cancel a labeling job and release unused credits").argument("<id>", "Labeling job ID (full UUID or 4+ char prefix)").action(async (id) => {
1657
+ const opts = parent.opts();
1658
+ const jobId = await resolveLabelingJobId(id, opts);
1659
+ const { data } = await post(
1660
+ `/labeling-jobs/${jobId}/cancel`,
1661
+ {},
1662
+ opts
1663
+ );
1664
+ if (isJsonMode()) return printJson(data);
1665
+ printSuccess(`Labeling job cancelled: ${shortId(jobId)}`);
1666
+ });
1667
+ }
1668
+
1156
1669
  // src/commands/models.ts
1157
1670
  import { spawn as spawn2, execFileSync } from "child_process";
1158
1671
  import { createHash } from "crypto";
1159
1672
  import {
1160
- existsSync as existsSync4,
1673
+ existsSync as existsSync5,
1161
1674
  mkdirSync as mkdirSync3,
1162
- statSync as statSync3,
1675
+ statSync as statSync4,
1163
1676
  createWriteStream,
1164
1677
  unlinkSync,
1165
- readFileSync as readFileSync6,
1678
+ readFileSync as readFileSync7,
1166
1679
  writeFileSync as writeFileSync3,
1167
1680
  readdirSync,
1168
1681
  rmSync
1169
1682
  } from "fs";
1170
1683
  import { homedir as homedir2 } from "os";
1171
- import { dirname as dirname2, join as join2, resolve as resolve2, basename as basename2 } from "path";
1684
+ import { dirname as dirname2, join as join2, resolve as resolve2, basename as basename2, normalize, isAbsolute } from "path";
1172
1685
  import { Readable, Transform } from "stream";
1173
1686
  import { pipeline } from "stream/promises";
1174
1687
 
1175
1688
  // src/commands/init.ts
1176
- import { existsSync as existsSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1689
+ import { existsSync as existsSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "fs";
1177
1690
  import { resolve } from "path";
1178
1691
  var DEFAULT_SPEC_FILE = "tunedtensor.json";
1179
1692
  var SCAFFOLD = {
@@ -1191,7 +1704,7 @@ var SCAFFOLD = {
1191
1704
  function registerInitCommand(parent) {
1192
1705
  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) => {
1193
1706
  const filePath = resolve(cmdOpts.file);
1194
- if (existsSync3(filePath)) {
1707
+ if (existsSync4(filePath)) {
1195
1708
  printWarning(`${cmdOpts.file} already exists. Use tt eval to validate it or edit it directly.`);
1196
1709
  return;
1197
1710
  }
@@ -1208,14 +1721,14 @@ function registerInitCommand(parent) {
1208
1721
  }
1209
1722
  function loadSpec(filePath) {
1210
1723
  const resolved = resolve(filePath || DEFAULT_SPEC_FILE);
1211
- if (!existsSync3(resolved)) {
1724
+ if (!existsSync4(resolved)) {
1212
1725
  printError(
1213
1726
  `Spec file not found: ${filePath || DEFAULT_SPEC_FILE}
1214
1727
  Run \`tt init\` to create one.`
1215
1728
  );
1216
1729
  process.exit(1);
1217
1730
  }
1218
- const raw = JSON.parse(readFileSync5(resolved, "utf-8"));
1731
+ const raw = JSON.parse(readFileSync6(resolved, "utf-8"));
1219
1732
  return raw;
1220
1733
  }
1221
1734
 
@@ -1230,7 +1743,7 @@ var COMPLETION_ROUTES = /* @__PURE__ */ new Set(["/v1/chat/completions", "/chat/
1230
1743
  function parsePositiveInteger(value, fallback) {
1231
1744
  return Number.isInteger(value) && value >= 0 ? value : fallback;
1232
1745
  }
1233
- function sleep(ms) {
1746
+ function sleep2(ms) {
1234
1747
  return new Promise((resolve4) => setTimeout(resolve4, ms));
1235
1748
  }
1236
1749
  function readRequestBody(req) {
@@ -1552,7 +2065,7 @@ var ManagedModelServer = class {
1552
2065
  child.kill("SIGTERM");
1553
2066
  await Promise.race([
1554
2067
  new Promise((resolve4) => child.once("exit", () => resolve4())),
1555
- sleep(5e3).then(() => {
2068
+ sleep2(5e3).then(() => {
1556
2069
  if (child.exitCode == null && child.signalCode == null) child.kill("SIGKILL");
1557
2070
  })
1558
2071
  ]);
@@ -1582,7 +2095,7 @@ var ManagedModelServer = class {
1582
2095
  const health = await this.checkHealth();
1583
2096
  if (health.ok) return;
1584
2097
  if (!this.child) break;
1585
- await sleep(50);
2098
+ await sleep2(50);
1586
2099
  }
1587
2100
  throw new Error("Model server did not become healthy within 120 seconds");
1588
2101
  }
@@ -1663,7 +2176,10 @@ import json
1663
2176
  import os
1664
2177
  import time
1665
2178
  import uuid
2179
+ import base64
2180
+ from io import BytesIO
1666
2181
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
2182
+ from urllib.request import urlopen
1667
2183
 
1668
2184
  MODEL_PATH = os.environ["TT_MODEL_PATH"]
1669
2185
  MODEL_NAME = os.environ.get("TT_MODEL_NAME", os.path.basename(MODEL_PATH.rstrip("/")) or "tuned-tensor-model")
@@ -1714,9 +2230,9 @@ def load_model():
1714
2230
  ) from exc
1715
2231
 
1716
2232
  try:
1717
- processor = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
1718
- except Exception:
1719
2233
  processor = AutoProcessor.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
2234
+ except Exception:
2235
+ processor = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
1720
2236
  tokenizer = getattr(processor, "tokenizer", processor)
1721
2237
  if getattr(tokenizer, "pad_token", None) is None:
1722
2238
  tokenizer.pad_token = tokenizer.eos_token
@@ -1733,22 +2249,43 @@ def load_model():
1733
2249
  kwargs["torch_dtype"] = torch.float16
1734
2250
 
1735
2251
  try:
1736
- model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **kwargs)
1737
- except Exception:
1738
2252
  model = AutoModelForImageTextToText.from_pretrained(MODEL_PATH, **kwargs)
2253
+ except Exception:
2254
+ model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **kwargs)
1739
2255
  if device != "cuda":
1740
2256
  model.to(device)
1741
2257
  model.eval()
1742
- return torch, tokenizer, model, device
2258
+ return torch, processor, tokenizer, model, device
2259
+
2260
+
2261
+ torch, processor, tokenizer, model, DEVICE = load_model()
1743
2262
 
1744
2263
 
1745
- torch, tokenizer, model, DEVICE = load_model()
2264
+ def normalize_content_part(part):
2265
+ if not isinstance(part, dict):
2266
+ return {"type": "text", "text": str(part)}
2267
+ part_type = part.get("type")
2268
+ if part_type == "text":
2269
+ return {"type": "text", "text": str(part.get("text", ""))}
2270
+ if part_type in {"image", "image_url"}:
2271
+ image_url = part.get("image_url")
2272
+ url = None
2273
+ if isinstance(image_url, dict):
2274
+ url = image_url.get("url")
2275
+ elif isinstance(image_url, str):
2276
+ url = image_url
2277
+ url = url or part.get("image") or part.get("data_uri") or part.get("uri") or part.get("path")
2278
+ if isinstance(url, str) and url.strip():
2279
+ return {"type": "image", "image": url.strip()}
2280
+ return {"type": "text", "text": json.dumps(part, ensure_ascii=False)}
1746
2281
 
1747
2282
 
1748
- def content_to_text(content):
2283
+ def normalize_content(content):
1749
2284
  if isinstance(content, str):
1750
2285
  return content
1751
- return json.dumps(content, ensure_ascii=False)
2286
+ if isinstance(content, list):
2287
+ return [normalize_content_part(part) for part in content]
2288
+ return str(content) if content is not None else ""
1752
2289
 
1753
2290
 
1754
2291
  def normalize_messages(raw_messages):
@@ -1759,7 +2296,7 @@ def normalize_messages(raw_messages):
1759
2296
  role = item.get("role")
1760
2297
  if role not in {"system", "user", "assistant", "tool"}:
1761
2298
  continue
1762
- messages.append({"role": role, "content": content_to_text(item.get("content", ""))})
2299
+ messages.append({"role": role, "content": normalize_content(item.get("content", ""))})
1763
2300
 
1764
2301
  if SYSTEM_PROMPT and not any(message["role"] == "system" for message in messages):
1765
2302
  messages.insert(0, {"role": "system", "content": SYSTEM_PROMPT})
@@ -1803,7 +2340,10 @@ def with_json_contract(messages, mode, schema):
1803
2340
  contracted = [dict(message) for message in messages]
1804
2341
  for message in contracted:
1805
2342
  if message["role"] == "system":
1806
- message["content"] = (message["content"].rstrip() + "\n\n" + contract).strip()
2343
+ content = message.get("content", "")
2344
+ if not isinstance(content, str):
2345
+ content = json.dumps(content, ensure_ascii=False)
2346
+ message["content"] = (content.rstrip() + "\n\n" + contract).strip()
1807
2347
  return contracted
1808
2348
 
1809
2349
  contracted.insert(0, {"role": "system", "content": contract})
@@ -1822,13 +2362,71 @@ def render_prompt(messages):
1822
2362
  return "\n".join(rendered)
1823
2363
 
1824
2364
 
1825
- def generate_completion(messages, max_tokens, temperature):
1826
- prompt = render_prompt(messages)
1827
- inputs = tokenizer(prompt, return_tensors="pt")
2365
+ def load_image(value):
2366
+ try:
2367
+ from PIL import Image
2368
+ except Exception as exc:
2369
+ raise RuntimeError(
2370
+ "Multimodal requests require Pillow. Install the serving runtime with "
2371
+ "'tt models setup-runtime --force' or install pillow in the selected Python environment."
2372
+ ) from exc
2373
+
2374
+ if value.startswith("data:image/"):
2375
+ _, payload = value.split(",", 1)
2376
+ return Image.open(BytesIO(base64.b64decode(payload))).convert("RGB")
2377
+ if value.startswith("http://") or value.startswith("https://"):
2378
+ with urlopen(value, timeout=30) as response:
2379
+ return Image.open(BytesIO(response.read())).convert("RGB")
2380
+ return Image.open(value).convert("RGB")
2381
+
2382
+
2383
+ def resolve_message_images(messages):
2384
+ resolved = []
2385
+ images = []
2386
+ for message in messages:
2387
+ content = message.get("content")
2388
+ if not isinstance(content, list):
2389
+ resolved.append(message)
2390
+ continue
2391
+
2392
+ parts = []
2393
+ for part in content:
2394
+ if not isinstance(part, dict) or part.get("type") != "image":
2395
+ parts.append(part)
2396
+ continue
2397
+ image_ref = part.get("image") or part.get("data_uri") or part.get("uri") or part.get("path")
2398
+ if not isinstance(image_ref, str) or not image_ref.strip():
2399
+ raise RuntimeError("Image content part must include image, image_url.url, data_uri, uri, or path")
2400
+ images.append(load_image(image_ref.strip()))
2401
+ parts.append({"type": "image"})
2402
+ resolved.append({**message, "content": parts})
2403
+ return resolved, images
2404
+
2405
+
2406
+ def move_inputs_to_device(inputs, device):
2407
+ moved = {}
2408
+ for key, value in inputs.items():
2409
+ moved[key] = value.to(device) if hasattr(value, "to") else value
2410
+ return moved
2411
+
2412
+
2413
+ def prepare_generation_inputs(messages):
2414
+ resolved_messages, images = resolve_message_images(messages)
2415
+ prompt = render_prompt(resolved_messages)
1828
2416
  input_device = getattr(model, "device", None)
1829
2417
  if input_device is None:
1830
2418
  input_device = next(model.parameters()).device
1831
- inputs = {key: value.to(input_device) for key, value in inputs.items()}
2419
+ if images:
2420
+ if processor is tokenizer:
2421
+ raise RuntimeError("This model artifact did not load a multimodal processor, so image inputs are not supported.")
2422
+ inputs = processor(text=[prompt], images=images, return_tensors="pt")
2423
+ else:
2424
+ inputs = tokenizer(prompt, return_tensors="pt")
2425
+ return move_inputs_to_device(inputs, input_device)
2426
+
2427
+
2428
+ def generate_completion(messages, max_tokens, temperature):
2429
+ inputs = prepare_generation_inputs(messages)
1832
2430
 
1833
2431
  generate_kwargs = {
1834
2432
  **inputs,
@@ -2091,7 +2689,7 @@ if __name__ == "__main__":
2091
2689
  `;
2092
2690
  function resolveOutputPath(output, filename) {
2093
2691
  if (!output) return filename;
2094
- if (existsSync4(output) && statSync3(output).isDirectory()) {
2692
+ if (existsSync5(output) && statSync4(output).isDirectory()) {
2095
2693
  return join2(output, filename);
2096
2694
  }
2097
2695
  return output;
@@ -2212,7 +2810,7 @@ function runtimePythonPath(cacheDir) {
2212
2810
  function resolveServePython(cacheDir, explicitPython) {
2213
2811
  if (explicitPython) return explicitPython;
2214
2812
  const managedPython = runtimePythonPath(cacheDir);
2215
- return existsSync4(managedPython) ? managedPython : "python3";
2813
+ return existsSync5(managedPython) ? managedPython : "python3";
2216
2814
  }
2217
2815
  function commandExists(command) {
2218
2816
  try {
@@ -2256,7 +2854,7 @@ function ensureServingRuntime(python, cacheDir) {
2256
2854
  const check = `
2257
2855
  import importlib.util
2258
2856
  import json
2259
- required = ["torch", "transformers", "accelerate", "safetensors"]
2857
+ required = ["torch", "transformers", "accelerate", "safetensors", "PIL"]
2260
2858
  missing = [name for name in required if importlib.util.find_spec(name) is None]
2261
2859
  print(json.dumps({"missing": missing}))
2262
2860
  `;
@@ -2291,11 +2889,28 @@ function safeSegment(value) {
2291
2889
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "model";
2292
2890
  }
2293
2891
  function directoryHasFiles(path) {
2294
- return existsSync4(path) && statSync3(path).isDirectory() && readdirSync(path).length > 0;
2892
+ return existsSync5(path) && statSync4(path).isDirectory() && readdirSync(path).length > 0;
2893
+ }
2894
+ function validateArchiveEntryPath(entry) {
2895
+ const normalized = normalize(entry).replace(/^[\\/]+/, "");
2896
+ if (!entry.trim() || isAbsolute(entry) || normalized === ".." || normalized.startsWith(`..${"/"}`) || normalized.startsWith(`..${"\\"}`)) {
2897
+ throw new Error(`Unsafe archive entry path: ${entry}`);
2898
+ }
2899
+ return normalized;
2900
+ }
2901
+ function validateArchiveEntries(archivePath) {
2902
+ const listing = execFileSync("tar", ["-tzf", archivePath], {
2903
+ encoding: "utf8",
2904
+ stdio: ["ignore", "pipe", "pipe"]
2905
+ });
2906
+ for (const entry of listing.split(/\r?\n/)) {
2907
+ if (entry.trim()) validateArchiveEntryPath(entry);
2908
+ }
2295
2909
  }
2296
2910
  function extractArchive(archivePath, targetDir, force) {
2297
2911
  if (directoryHasFiles(targetDir) && !force) return targetDir;
2298
- if (existsSync4(targetDir)) rmSync(targetDir, { recursive: true, force: true });
2912
+ validateArchiveEntries(archivePath);
2913
+ if (existsSync5(targetDir)) rmSync(targetDir, { recursive: true, force: true });
2299
2914
  mkdirSync3(targetDir, { recursive: true });
2300
2915
  execFileSync("tar", ["-xzf", archivePath, "-C", targetDir], { stdio: "inherit" });
2301
2916
  return targetDir;
@@ -2322,21 +2937,21 @@ function findSpecPath(explicitPath, modelPath) {
2322
2937
  const candidates = explicitPath ? [explicitPath] : [join2(process.cwd(), DEFAULT_SPEC_FILE), join2(modelPath, DEFAULT_SPEC_FILE)];
2323
2938
  for (const candidate of candidates) {
2324
2939
  const resolved = resolve2(candidate);
2325
- if (existsSync4(resolved) && statSync3(resolved).isFile()) return resolved;
2940
+ if (existsSync5(resolved) && statSync4(resolved).isFile()) return resolved;
2326
2941
  }
2327
2942
  return null;
2328
2943
  }
2329
2944
  function loadSystemPromptFromSpec(specPath) {
2330
- const spec = JSON.parse(readFileSync6(specPath, "utf8"));
2945
+ const spec = JSON.parse(readFileSync7(specPath, "utf8"));
2331
2946
  return buildSystemPrompt(spec);
2332
2947
  }
2333
2948
  function loadJsonSchemaForServe(schemaPath) {
2334
2949
  const resolved = resolve2(schemaPath);
2335
- if (!existsSync4(resolved) || !statSync3(resolved).isFile()) {
2950
+ if (!existsSync5(resolved) || !statSync4(resolved).isFile()) {
2336
2951
  throw new Error(`JSON schema file not found: ${schemaPath}`);
2337
2952
  }
2338
2953
  try {
2339
- const schema = JSON.parse(readFileSync6(resolved, "utf8"));
2954
+ const schema = JSON.parse(readFileSync7(resolved, "utf8"));
2340
2955
  return JSON.stringify(schema);
2341
2956
  } catch (err) {
2342
2957
  throw new Error(`JSON schema file is not valid JSON: ${schemaPath}`);
@@ -2350,9 +2965,9 @@ function writeReferenceServerScript(cacheDir) {
2350
2965
  return scriptPath;
2351
2966
  }
2352
2967
  async function resolveServeTarget(target, opts, cacheDir, forceDownload) {
2353
- if (existsSync4(target)) {
2968
+ if (existsSync5(target)) {
2354
2969
  const resolved = resolve2(target);
2355
- const stats = statSync3(resolved);
2970
+ const stats = statSync4(resolved);
2356
2971
  if (stats.isDirectory()) {
2357
2972
  return {
2358
2973
  modelPath: resolved,
@@ -2376,7 +2991,7 @@ async function resolveServeTarget(target, opts, cacheDir, forceDownload) {
2376
2991
  const modelCacheDir = join2(cacheDir, fullId);
2377
2992
  const archivePath = join2(modelCacheDir, data.filename);
2378
2993
  const extractDir = join2(modelCacheDir, "model");
2379
- if (!existsSync4(archivePath) || forceDownload) {
2994
+ if (!existsSync5(archivePath) || forceDownload) {
2380
2995
  await downloadUrlToFile(data.url, archivePath);
2381
2996
  }
2382
2997
  extractArchive(archivePath, extractDir, forceDownload);
@@ -2480,7 +3095,7 @@ function ollamaSlug(name) {
2480
3095
  }
2481
3096
  function firstExisting(candidates) {
2482
3097
  for (const candidate of candidates) {
2483
- if (candidate && existsSync4(candidate) && statSync3(candidate).isFile()) {
3098
+ if (candidate && existsSync5(candidate) && statSync4(candidate).isFile()) {
2484
3099
  return resolve2(candidate);
2485
3100
  }
2486
3101
  }
@@ -2492,7 +3107,7 @@ function llamaCppDir(explicit) {
2492
3107
  function resolveConvertScript(opts, required) {
2493
3108
  if (opts.convertScript) {
2494
3109
  const resolved = resolve2(opts.convertScript);
2495
- if (required && !existsSync4(resolved)) {
3110
+ if (required && !existsSync5(resolved)) {
2496
3111
  throw new Error(`Conversion script not found: ${opts.convertScript}`);
2497
3112
  }
2498
3113
  return resolved;
@@ -2511,7 +3126,7 @@ function resolveConvertScript(opts, required) {
2511
3126
  function resolveQuantizeBin(opts, required) {
2512
3127
  if (opts.quantizeBin) {
2513
3128
  const resolved = resolve2(opts.quantizeBin);
2514
- if (required && !existsSync4(resolved)) {
3129
+ if (required && !existsSync5(resolved)) {
2515
3130
  throw new Error(`llama-quantize binary not found: ${opts.quantizeBin}`);
2516
3131
  }
2517
3132
  return resolved;
@@ -2646,7 +3261,7 @@ function registerModelsCommands(parent) {
2646
3261
  opts
2647
3262
  );
2648
3263
  const outputPath = resolveOutputPath(cmdOpts.output, data.filename);
2649
- if (existsSync4(outputPath) && !cmdOpts.force) {
3264
+ if (existsSync5(outputPath) && !cmdOpts.force) {
2650
3265
  throw new Error(`Output file already exists: ${outputPath}. Use --force to overwrite.`);
2651
3266
  }
2652
3267
  const bytes = await downloadUrlToFile(data.url, outputPath);
@@ -2755,7 +3370,7 @@ function registerModelsCommands(parent) {
2755
3370
  ollama: ollamaInfo
2756
3371
  };
2757
3372
  if (printOnly) return printExportPlan(plan);
2758
- if (existsSync4(finalPath) && !cmdOpts.force) {
3373
+ if (existsSync5(finalPath) && !cmdOpts.force) {
2759
3374
  throw new Error(`Output already exists: ${finalPath}. Use --force to overwrite.`);
2760
3375
  }
2761
3376
  mkdirSync3(outputDir, { recursive: true });
@@ -2776,7 +3391,7 @@ function registerModelsCommands(parent) {
2776
3391
  finalPath,
2777
3392
  quantPlan.quantizeType
2778
3393
  ]);
2779
- if (!cmdOpts.keepIntermediate && intermediatePath && existsSync4(intermediatePath)) {
3394
+ if (!cmdOpts.keepIntermediate && intermediatePath && existsSync5(intermediatePath)) {
2780
3395
  rmSync(intermediatePath, { force: true });
2781
3396
  }
2782
3397
  }
@@ -2810,7 +3425,7 @@ function registerModelsCommands(parent) {
2810
3425
  const venvDir = runtimeDir(cacheDir);
2811
3426
  const venvPython = runtimePythonPath(cacheDir);
2812
3427
  const python = pickRuntimePython(cmdOpts.python);
2813
- const deps = ["torch", "transformers", "accelerate", "safetensors"];
3428
+ const deps = ["torch", "transformers", "accelerate", "safetensors", "pillow"];
2814
3429
  const commands = setupRuntimeCommands(python, venvDir, deps);
2815
3430
  if (cmdOpts.printCommand) {
2816
3431
  const commandLines = commands.map((cmd) => cmd.map(quoteCommandPart).join(" "));
@@ -2829,10 +3444,10 @@ function registerModelsCommands(parent) {
2829
3444
  ]);
2830
3445
  return;
2831
3446
  }
2832
- if (existsSync4(venvDir) && cmdOpts.force) {
3447
+ if (existsSync5(venvDir) && cmdOpts.force) {
2833
3448
  rmSync(venvDir, { recursive: true, force: true });
2834
3449
  }
2835
- if (!existsSync4(venvPython)) {
3450
+ if (!existsSync5(venvPython)) {
2836
3451
  mkdirSync3(cacheDir, { recursive: true });
2837
3452
  execFileSync(commands[0][0], commands[0].slice(1), { stdio: "inherit" });
2838
3453
  }
@@ -2973,15 +3588,15 @@ var KIND_LABELS = {
2973
3588
  refund: "Refund",
2974
3589
  adjustment: "Adjustment"
2975
3590
  };
2976
- function formatCents2(cents) {
3591
+ function formatCents3(cents) {
2977
3592
  const sign = cents < 0 ? "-" : "";
2978
3593
  const abs = Math.abs(cents);
2979
3594
  return `${sign}$${(abs / 100).toFixed(2)}`;
2980
3595
  }
2981
3596
  function formatSigned(cents) {
2982
- if (cents > 0) return chalk3.green(`+${formatCents2(cents)}`);
2983
- if (cents < 0) return chalk3.red(formatCents2(cents));
2984
- return formatCents2(cents);
3597
+ if (cents > 0) return chalk3.green(`+${formatCents3(cents)}`);
3598
+ if (cents < 0) return chalk3.red(formatCents3(cents));
3599
+ return formatCents3(cents);
2985
3600
  }
2986
3601
  function registerBalanceCommands(parent) {
2987
3602
  parent.command("balance").description("Show credit balance and recent transactions").option("-n, --limit <n>", "Number of transactions to show (default 10)", "10").action(async (options) => {
@@ -2995,10 +3610,18 @@ function registerBalanceCommands(parent) {
2995
3610
  return printJson({ balance, transactions });
2996
3611
  }
2997
3612
  const lowBalance = balance.balance_cents < 100;
2998
- const balanceLine = lowBalance ? chalk3.red(formatCents2(balance.balance_cents)) + chalk3.dim(" (low)") : chalk3.bold.green(formatCents2(balance.balance_cents));
3613
+ const balanceLine = lowBalance ? chalk3.red(formatCents3(balance.balance_cents)) + chalk3.dim(" (low)") : chalk3.bold.green(formatCents3(balance.balance_cents));
2999
3614
  printDetail([
3000
3615
  ["Credits", balanceLine],
3001
- ["Lifetime top-ups", formatCents2(balance.lifetime_topup_cents)]
3616
+ [
3617
+ "Reserved",
3618
+ balance.reserved_cents > 0 ? formatCents3(balance.reserved_cents) : void 0
3619
+ ],
3620
+ [
3621
+ "Available",
3622
+ balance.reserved_cents > 0 ? formatCents3(balance.available_cents) : void 0
3623
+ ],
3624
+ ["Lifetime top-ups", formatCents3(balance.lifetime_topup_cents)]
3002
3625
  ]);
3003
3626
  if (transactions && transactions.length > 0) {
3004
3627
  console.log(`
@@ -3009,7 +3632,7 @@ ${chalk3.bold("Recent transactions")}`);
3009
3632
  formatDate(row.created_at),
3010
3633
  KIND_LABELS[row.kind] ?? row.kind,
3011
3634
  formatSigned(row.amount_cents),
3012
- formatCents2(row.balance_after_cents),
3635
+ formatCents3(row.balance_after_cents),
3013
3636
  row.reference_id ? shortId(row.reference_id) : chalk3.dim("\u2014")
3014
3637
  ])
3015
3638
  );
@@ -3307,7 +3930,7 @@ function printValidation(checks) {
3307
3930
  }
3308
3931
 
3309
3932
  // src/commands/push.ts
3310
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
3933
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
3311
3934
  import { resolve as resolve3 } from "path";
3312
3935
  function registerPushCommand(parent) {
3313
3936
  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) => {
@@ -3327,7 +3950,7 @@ function registerPushCommand(parent) {
3327
3950
  } else {
3328
3951
  const res = await post("/behavior-specs", body, opts);
3329
3952
  data = res.data;
3330
- const raw = JSON.parse(readFileSync7(filePath, "utf-8"));
3953
+ const raw = JSON.parse(readFileSync8(filePath, "utf-8"));
3331
3954
  raw.id = data.id;
3332
3955
  writeFileSync4(filePath, JSON.stringify(raw, null, 2) + "\n");
3333
3956
  }
@@ -3340,7 +3963,7 @@ function registerPushCommand(parent) {
3340
3963
 
3341
3964
  // src/index.ts
3342
3965
  var program = new Command();
3343
- program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.19").option("-k, --api-key <key>", "API key (overrides stored key)").option(
3966
+ program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.21").option("-k, --api-key <key>", "API key (overrides stored key)").option(
3344
3967
  "-u, --base-url <url>",
3345
3968
  "API base URL (default: https://tunedtensor.com)"
3346
3969
  ).option("--json", "Output raw JSON").option("--no-color", "Disable colors").hook("preAction", (_thisCommand, actionCommand) => {
@@ -3354,6 +3977,7 @@ registerAuthCommands(program);
3354
3977
  registerSpecsCommands(program);
3355
3978
  registerRunsCommands(program);
3356
3979
  registerDatasetsCommands(program);
3980
+ registerLabelCommands(program);
3357
3981
  registerModelsCommands(program);
3358
3982
  registerBalanceCommands(program);
3359
3983
  registerTopupCommands(program);