@tuned-tensor/cli 0.4.20 → 0.4.22
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 +377 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -387,6 +387,7 @@ var SUPPORTED_BASE_MODELS = [
|
|
|
387
387
|
"google/gemma-4-E2B-it",
|
|
388
388
|
"google/gemma-4-E4B-it",
|
|
389
389
|
"Qwen/Qwen3.5-2B",
|
|
390
|
+
"Qwen/Qwen3-VL-2B-Instruct",
|
|
390
391
|
"Qwen/Qwen3.5-4B",
|
|
391
392
|
"meta-llama/Llama-3.2-3B-Instruct",
|
|
392
393
|
"microsoft/Phi-4-mini-instruct",
|
|
@@ -407,6 +408,9 @@ for (const [alias, model] of [
|
|
|
407
408
|
["qwen/qwen3.5-2b", "Qwen/Qwen3.5-2B"],
|
|
408
409
|
["Qwen/Qwen3.5-2B-Base", "Qwen/Qwen3.5-2B"],
|
|
409
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"],
|
|
410
414
|
["qwen/qwen3.5-4b", "Qwen/Qwen3.5-4B"],
|
|
411
415
|
["Qwen/Qwen3.5-4B-Base", "Qwen/Qwen3.5-4B"],
|
|
412
416
|
["qwen/qwen3.5-4b-base", "Qwen/Qwen3.5-4B"],
|
|
@@ -621,6 +625,14 @@ function formatEvalScore(run) {
|
|
|
621
625
|
function formatPercent(rate) {
|
|
622
626
|
return rate == null ? void 0 : (rate * 100).toFixed(1) + "%";
|
|
623
627
|
}
|
|
628
|
+
function formatPointDelta(delta) {
|
|
629
|
+
if (delta == null) return void 0;
|
|
630
|
+
const points = delta * 100;
|
|
631
|
+
return `${points >= 0 ? "+" : ""}${points.toFixed(1)} pp`;
|
|
632
|
+
}
|
|
633
|
+
function formatScore(score) {
|
|
634
|
+
return score == null ? "\u2014" : score.toFixed(2);
|
|
635
|
+
}
|
|
624
636
|
function formatCountRate(count, total, rate) {
|
|
625
637
|
if (count == null || total == null || rate == null) return void 0;
|
|
626
638
|
return `${formatPercent(rate)} (${count}/${total})`;
|
|
@@ -758,6 +770,131 @@ function printEvalOutputDiagnostics(diagnostics) {
|
|
|
758
770
|
}
|
|
759
771
|
}
|
|
760
772
|
}
|
|
773
|
+
function compactWhitespace(value) {
|
|
774
|
+
return value.replace(/\s+/g, " ").trim();
|
|
775
|
+
}
|
|
776
|
+
function parseJsonish(value) {
|
|
777
|
+
if (!value) return value;
|
|
778
|
+
try {
|
|
779
|
+
return JSON.parse(value);
|
|
780
|
+
} catch {
|
|
781
|
+
return value;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function formatJsonish(value, max = 360) {
|
|
785
|
+
const parsed = parseJsonish(value);
|
|
786
|
+
const rendered = typeof parsed === "string" ? compactWhitespace(parsed) : JSON.stringify(parsed);
|
|
787
|
+
return truncate(rendered ?? "\u2014", max);
|
|
788
|
+
}
|
|
789
|
+
function formatPromptSummary(prompt, max = 260) {
|
|
790
|
+
const subject = prompt.match(/^Subject:\s*(.+)$/m)?.[1]?.trim();
|
|
791
|
+
const body = prompt.match(/^Body:\s*([\s\S]+)$/m)?.[1];
|
|
792
|
+
if (subject) {
|
|
793
|
+
const bodySummary = body ? ` | Body: ${compactWhitespace(body)}` : "";
|
|
794
|
+
return truncate(`Subject: ${subject}${bodySummary}`, max);
|
|
795
|
+
}
|
|
796
|
+
return truncate(compactWhitespace(prompt), max);
|
|
797
|
+
}
|
|
798
|
+
function indexResults(results) {
|
|
799
|
+
return new Map((results ?? []).map((result) => [result.prompt, result]));
|
|
800
|
+
}
|
|
801
|
+
function printReportMetrics(label, split) {
|
|
802
|
+
if (!split) return;
|
|
803
|
+
const comparison = split.comparison;
|
|
804
|
+
const baseline = split.baseline;
|
|
805
|
+
const candidate = split.candidate;
|
|
806
|
+
console.log(`
|
|
807
|
+
${label} Metrics:`);
|
|
808
|
+
printDetail([
|
|
809
|
+
["Base Avg", formatPercent(baseline?.avg_score)],
|
|
810
|
+
["Tuned Avg", formatPercent(candidate?.avg_score)],
|
|
811
|
+
["Avg Delta", formatPointDelta(comparison?.avg_score_delta)],
|
|
812
|
+
["Base Pass", formatPercent(baseline?.pass_rate)],
|
|
813
|
+
["Tuned Pass", formatPercent(candidate?.pass_rate)],
|
|
814
|
+
["Pass Delta", formatPointDelta(comparison?.pass_rate_delta)],
|
|
815
|
+
["Regressions", comparison?.regressions == null ? void 0 : String(comparison.regressions)],
|
|
816
|
+
["Improvements", comparison?.improvements == null ? void 0 : String(comparison.improvements)],
|
|
817
|
+
["Eval Rows", candidate?.eval_examples_used == null ? candidate?.total == null ? void 0 : String(candidate.total) : String(candidate.eval_examples_used)]
|
|
818
|
+
]);
|
|
819
|
+
}
|
|
820
|
+
function printComparedExample(input) {
|
|
821
|
+
const { index, prompt, baseline, candidate, oldScore, newScore } = input;
|
|
822
|
+
const expected = candidate?.expected ?? baseline?.expected;
|
|
823
|
+
const scoreLine = oldScore != null || newScore != null ? oldScore == null ? `tuned score ${formatScore(newScore)}` : `score ${formatScore(oldScore)} -> ${formatScore(newScore)}` : `tuned score ${formatScore(candidate?.score)}`;
|
|
824
|
+
console.log(`
|
|
825
|
+
${index}. ${scoreLine}`);
|
|
826
|
+
console.log(` Prompt: ${formatPromptSummary(prompt)}`);
|
|
827
|
+
console.log(` Expected: ${formatJsonish(expected)}`);
|
|
828
|
+
if (baseline?.actual != null) {
|
|
829
|
+
console.log(` Base: ${formatJsonish(baseline.actual)}`);
|
|
830
|
+
}
|
|
831
|
+
if (candidate?.actual != null) {
|
|
832
|
+
console.log(` Tuned: ${formatJsonish(candidate.actual)}`);
|
|
833
|
+
}
|
|
834
|
+
const reasoning = candidate?.reasoning ?? baseline?.reasoning;
|
|
835
|
+
if (reasoning) {
|
|
836
|
+
console.log(` Judge: ${truncate(compactWhitespace(reasoning), 420)}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function printReportExamples(label, split, mode, limit) {
|
|
840
|
+
if (!split || limit <= 0) return;
|
|
841
|
+
const baselineByPrompt = indexResults(split.baseline?.results);
|
|
842
|
+
const candidateByPrompt = indexResults(split.candidate?.results);
|
|
843
|
+
if (mode === "regressions") {
|
|
844
|
+
const regressions = split.comparison?.regressed_examples ?? [];
|
|
845
|
+
console.log(`
|
|
846
|
+
${label} Regressions:`);
|
|
847
|
+
if (!regressions.length) {
|
|
848
|
+
console.log(" No regressed examples in the report.");
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
regressions.slice(0, limit).forEach((example, i) => {
|
|
852
|
+
printComparedExample({
|
|
853
|
+
index: i + 1,
|
|
854
|
+
prompt: example.prompt,
|
|
855
|
+
baseline: baselineByPrompt.get(example.prompt),
|
|
856
|
+
candidate: candidateByPrompt.get(example.prompt),
|
|
857
|
+
oldScore: example.old_score,
|
|
858
|
+
newScore: example.new_score
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const failures = (split.candidate?.results ?? []).filter((result) => result.passed === false || (result.score ?? 1) < 1).sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
864
|
+
console.log(`
|
|
865
|
+
${label} Tuned Failures:`);
|
|
866
|
+
if (!failures.length) {
|
|
867
|
+
console.log(" No tuned failures in the report.");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
failures.slice(0, limit).forEach((candidate, i) => {
|
|
871
|
+
printComparedExample({
|
|
872
|
+
index: i + 1,
|
|
873
|
+
prompt: candidate.prompt,
|
|
874
|
+
baseline: baselineByPrompt.get(candidate.prompt),
|
|
875
|
+
candidate,
|
|
876
|
+
newScore: candidate.score
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
function printRunReport(report, options) {
|
|
881
|
+
printDetail([
|
|
882
|
+
["Run", report.run_id ? shortId(report.run_id) : void 0],
|
|
883
|
+
["Status", report.status ? formatStatus(report.status) : void 0],
|
|
884
|
+
["Model", report.fine_tuned_model_id ? shortId(report.fine_tuned_model_id) : void 0]
|
|
885
|
+
]);
|
|
886
|
+
const splits = [];
|
|
887
|
+
if (options.split === "primary" || options.split === "all") {
|
|
888
|
+
splits.push(["Primary", report]);
|
|
889
|
+
}
|
|
890
|
+
if (options.split === "test" || options.split === "all") {
|
|
891
|
+
splits.push(["Test", report.test]);
|
|
892
|
+
}
|
|
893
|
+
for (const [label, split] of splits) {
|
|
894
|
+
printReportMetrics(label, split);
|
|
895
|
+
printReportExamples(label, split, options.mode, options.limit);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
761
898
|
function addRunConfigurationOptions(command) {
|
|
762
899
|
return command.option("--no-augment", "Disable data augmentation").option("--no-llm-judge", "Disable LLM judging").option("--epochs <n>", "Number of training epochs").option("--lr <rate>", "Learning rate").option("--batch-size <n>", "Batch size").option("--dataset <id>", "Dataset ID to use instead of inline spec examples (full UUID or 4+ char prefix)").option("--parent-model <id>", "Fine-tuned model ID to continue training from (full UUID or 4+ char prefix)").option("--train-ratio <ratio>", "Dataset training split ratio (default: 0.8 when any split ratio is set)").option("--validation-ratio <ratio>", "Dataset validation split ratio (default: 0.1 when any split ratio is set)").option("--test-ratio <ratio>", "Dataset test split ratio (default: 0.1 when any split ratio is set)").option("--lora-rank <n>", "LoRA rank").option("--lora-alpha <n>", "LoRA alpha").option("--max-eval-examples <n>", "Max examples for the primary eval pass").option("--max-test-eval-examples <n>", "Max examples for the secondary test eval pass").option("--long-examples <policy>", "Long training row policy: error, truncate, or skip").option("--max-seq-length <tokens>", "Maximum training sequence length in tokens").option("--max-output-tokens <tokens>", "Desired evaluation output budget in tokens").option("--eval-reserved-output-tokens <tokens>", "Minimum evaluation output tokens reserved per row");
|
|
763
900
|
}
|
|
@@ -955,10 +1092,38 @@ Eval Results (${data._evals.length}):`);
|
|
|
955
1092
|
if (isJsonMode()) return printJson(data);
|
|
956
1093
|
printDiagnostics(data);
|
|
957
1094
|
});
|
|
1095
|
+
runs.command("report").description("Show run metrics and side-by-side eval output insights").argument("<id>", "Run ID (full UUID or 8+ char prefix)").option("--split <split>", "Split to show: primary, test, or all", "primary").option("--limit <n>", "Number of examples to show", "5").option("--mode <mode>", "Example mode: regressions or failures", "regressions").action(async (id, cmdOpts) => {
|
|
1096
|
+
const opts = parent.opts();
|
|
1097
|
+
const fullId = await resolveRunId(id, opts);
|
|
1098
|
+
const { data } = await get(
|
|
1099
|
+
`/runs/${fullId}/report`,
|
|
1100
|
+
void 0,
|
|
1101
|
+
opts
|
|
1102
|
+
);
|
|
1103
|
+
if (isJsonMode()) return printJson(data);
|
|
1104
|
+
const split = String(cmdOpts.split);
|
|
1105
|
+
if (!["primary", "test", "all"].includes(split)) {
|
|
1106
|
+
throw new Error("--split must be one of: primary, test, all");
|
|
1107
|
+
}
|
|
1108
|
+
const mode = String(cmdOpts.mode);
|
|
1109
|
+
if (!["regressions", "failures"].includes(mode)) {
|
|
1110
|
+
throw new Error("--mode must be one of: regressions, failures");
|
|
1111
|
+
}
|
|
1112
|
+
const limit = Number(cmdOpts.limit);
|
|
1113
|
+
if (!Number.isFinite(limit) || limit < 0) {
|
|
1114
|
+
throw new Error("--limit must be a non-negative number");
|
|
1115
|
+
}
|
|
1116
|
+
printRunReport(data, {
|
|
1117
|
+
split,
|
|
1118
|
+
mode,
|
|
1119
|
+
limit: Math.floor(limit)
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
958
1122
|
}
|
|
959
1123
|
|
|
960
1124
|
// src/commands/datasets.ts
|
|
961
1125
|
import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
|
|
1126
|
+
var DOCUMENT_OCR_MIME_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
962
1127
|
function formatBytes(bytes) {
|
|
963
1128
|
if (bytes < 1024) return `${bytes} B`;
|
|
964
1129
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -1012,10 +1177,85 @@ function findControlChar(value) {
|
|
|
1012
1177
|
function formatCodepoint(code) {
|
|
1013
1178
|
return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
1014
1179
|
}
|
|
1015
|
-
function
|
|
1180
|
+
function detectRowFormat(record) {
|
|
1181
|
+
const input = record.input;
|
|
1182
|
+
if (input && typeof input === "object" && typeof input.prompt === "string" && Array.isArray(input.assets) && input.assets.length > 0 && typeof record.output === "string") {
|
|
1183
|
+
return "document_ocr_jsonl";
|
|
1184
|
+
}
|
|
1185
|
+
if (typeof record.input === "string" || typeof record.output === "string" || !("input" in record) || !("output" in record)) {
|
|
1186
|
+
return "jsonl";
|
|
1187
|
+
}
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
function validateDocumentOcrRow(record, rowNumber, errors) {
|
|
1191
|
+
const input = record.input;
|
|
1192
|
+
const assets = input.assets;
|
|
1193
|
+
if (typeof input.prompt !== "string" || input.prompt.trim().length === 0) {
|
|
1194
|
+
errors.push(`Row ${rowNumber}: OCR input.prompt must be a non-empty string`);
|
|
1195
|
+
} else {
|
|
1196
|
+
const hit = findControlChar(input.prompt);
|
|
1197
|
+
if (hit) {
|
|
1198
|
+
errors.push(
|
|
1199
|
+
`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`
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (typeof record.output !== "string" || record.output.trim().length === 0) {
|
|
1204
|
+
errors.push(`Row ${rowNumber}: OCR output must be a non-empty string`);
|
|
1205
|
+
} else {
|
|
1206
|
+
const hit = findControlChar(record.output);
|
|
1207
|
+
if (hit) {
|
|
1208
|
+
errors.push(
|
|
1209
|
+
`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`
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (assets.length > 8) {
|
|
1214
|
+
errors.push(`Row ${rowNumber}: OCR rows support at most 8 image assets`);
|
|
1215
|
+
}
|
|
1216
|
+
for (const [assetIndex, asset] of assets.entries()) {
|
|
1217
|
+
if (!asset || typeof asset !== "object" || Array.isArray(asset)) {
|
|
1218
|
+
errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} must be an object`);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
const a = asset;
|
|
1222
|
+
if (a.type !== void 0 && a.type !== "image") {
|
|
1223
|
+
errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} type must be "image"`);
|
|
1224
|
+
}
|
|
1225
|
+
if (a.mime_type !== void 0 && (typeof a.mime_type !== "string" || !DOCUMENT_OCR_MIME_TYPES.has(a.mime_type))) {
|
|
1226
|
+
errors.push(
|
|
1227
|
+
`Row ${rowNumber}: asset ${assetIndex + 1} mime_type must be one of image/png, image/jpeg, image/webp`
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
if (a.page !== void 0 && (typeof a.page !== "number" || !Number.isInteger(a.page) || a.page < 1)) {
|
|
1231
|
+
errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} page must be a positive integer`);
|
|
1232
|
+
}
|
|
1233
|
+
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;
|
|
1234
|
+
if (!hasReference) {
|
|
1235
|
+
errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} must include data_uri, uri, or path`);
|
|
1236
|
+
}
|
|
1237
|
+
if (typeof a.data_uri === "string") {
|
|
1238
|
+
const match = /^data:(image\/(?:png|jpeg|webp));base64,[A-Za-z0-9+/=\s]+$/.exec(a.data_uri);
|
|
1239
|
+
if (!match) {
|
|
1240
|
+
errors.push(
|
|
1241
|
+
`Row ${rowNumber}: asset ${assetIndex + 1} data_uri must be a base64 image/png, image/jpeg, or image/webp data URI`
|
|
1242
|
+
);
|
|
1243
|
+
} else if (typeof a.mime_type === "string" && a.mime_type !== match[1]) {
|
|
1244
|
+
errors.push(`Row ${rowNumber}: asset ${assetIndex + 1} mime_type does not match data_uri media type`);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
function parseDatasetFormat(value) {
|
|
1250
|
+
if (!value) return void 0;
|
|
1251
|
+
if (value === "jsonl" || value === "document_ocr_jsonl") return value;
|
|
1252
|
+
throw new Error("--format must be one of: jsonl, document_ocr_jsonl");
|
|
1253
|
+
}
|
|
1254
|
+
function validateDatasetFile(file, requestedFormat) {
|
|
1016
1255
|
const lines = readFileSync4(file, "utf8").split(/\r?\n/);
|
|
1017
1256
|
const errors = [];
|
|
1018
1257
|
let rowCount = 0;
|
|
1258
|
+
let detectedFormat = null;
|
|
1019
1259
|
for (const [index, rawLine] of lines.entries()) {
|
|
1020
1260
|
const line = rawLine.trim();
|
|
1021
1261
|
if (!line) continue;
|
|
@@ -1039,6 +1279,26 @@ function validateDatasetFile(file) {
|
|
|
1039
1279
|
);
|
|
1040
1280
|
continue;
|
|
1041
1281
|
}
|
|
1282
|
+
const rowFormat = detectRowFormat(record);
|
|
1283
|
+
if (!rowFormat) {
|
|
1284
|
+
errors.push(
|
|
1285
|
+
`Row ${rowNumber}: expected text {"input": string, "output": string} or OCR {"input":{"prompt": string, "assets": [...]}, "output": string}`
|
|
1286
|
+
);
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
detectedFormat ??= rowFormat;
|
|
1290
|
+
if (requestedFormat && rowFormat !== requestedFormat) {
|
|
1291
|
+
errors.push(`Row ${rowNumber}: expected ${requestedFormat} row, found ${rowFormat}`);
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
if (detectedFormat !== rowFormat) {
|
|
1295
|
+
errors.push(`Row ${rowNumber}: cannot mix ${detectedFormat} and ${rowFormat} rows in one dataset`);
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (rowFormat === "document_ocr_jsonl") {
|
|
1299
|
+
validateDocumentOcrRow(record, rowNumber, errors);
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1042
1302
|
if (typeof record.input !== "string") {
|
|
1043
1303
|
errors.push(`Row ${rowNumber}: missing string "input" field`);
|
|
1044
1304
|
}
|
|
@@ -1064,10 +1324,11 @@ function validateDatasetFile(file) {
|
|
|
1064
1324
|
const suffix = errors.length > 5 ? `
|
|
1065
1325
|
...and ${errors.length - 5} more error(s)` : "";
|
|
1066
1326
|
throw new Error(
|
|
1067
|
-
`Invalid dataset format. Each JSONL row must be
|
|
1327
|
+
`Invalid dataset format. Each JSONL row must be a valid text or document OCR example.
|
|
1068
1328
|
${preview}${suffix}`
|
|
1069
1329
|
);
|
|
1070
1330
|
}
|
|
1331
|
+
return detectedFormat ?? requestedFormat ?? "jsonl";
|
|
1071
1332
|
}
|
|
1072
1333
|
function registerDatasetsCommands(parent) {
|
|
1073
1334
|
const datasets = parent.command("datasets").description("Manage datasets");
|
|
@@ -1115,13 +1376,13 @@ function registerDatasetsCommands(parent) {
|
|
|
1115
1376
|
}
|
|
1116
1377
|
}
|
|
1117
1378
|
});
|
|
1118
|
-
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) => {
|
|
1379
|
+
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) => {
|
|
1119
1380
|
const opts = parent.opts();
|
|
1120
1381
|
if (!existsSync2(file)) {
|
|
1121
1382
|
printError(`File not found: ${file}`);
|
|
1122
1383
|
process.exit(1);
|
|
1123
1384
|
}
|
|
1124
|
-
validateDatasetFile(file);
|
|
1385
|
+
const format = validateDatasetFile(file, parseDatasetFormat(cmdOpts.format));
|
|
1125
1386
|
const name = cmdOpts.name || file.split("/").pop().replace(/\.jsonl?$/, "");
|
|
1126
1387
|
const fileBytes = readFileSync4(file);
|
|
1127
1388
|
const uploadUrl = await post(
|
|
@@ -1131,7 +1392,8 @@ function registerDatasetsCommands(parent) {
|
|
|
1131
1392
|
description: cmdOpts.description ?? null,
|
|
1132
1393
|
filename: file.split("/").pop(),
|
|
1133
1394
|
size: statSync2(file).size,
|
|
1134
|
-
contentType: "application/jsonl"
|
|
1395
|
+
contentType: "application/jsonl",
|
|
1396
|
+
format
|
|
1135
1397
|
},
|
|
1136
1398
|
opts
|
|
1137
1399
|
);
|
|
@@ -2074,7 +2336,10 @@ import json
|
|
|
2074
2336
|
import os
|
|
2075
2337
|
import time
|
|
2076
2338
|
import uuid
|
|
2339
|
+
import base64
|
|
2340
|
+
from io import BytesIO
|
|
2077
2341
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
2342
|
+
from urllib.request import urlopen
|
|
2078
2343
|
|
|
2079
2344
|
MODEL_PATH = os.environ["TT_MODEL_PATH"]
|
|
2080
2345
|
MODEL_NAME = os.environ.get("TT_MODEL_NAME", os.path.basename(MODEL_PATH.rstrip("/")) or "tuned-tensor-model")
|
|
@@ -2125,9 +2390,9 @@ def load_model():
|
|
|
2125
2390
|
) from exc
|
|
2126
2391
|
|
|
2127
2392
|
try:
|
|
2128
|
-
processor = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
|
|
2129
|
-
except Exception:
|
|
2130
2393
|
processor = AutoProcessor.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
|
|
2394
|
+
except Exception:
|
|
2395
|
+
processor = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
|
|
2131
2396
|
tokenizer = getattr(processor, "tokenizer", processor)
|
|
2132
2397
|
if getattr(tokenizer, "pad_token", None) is None:
|
|
2133
2398
|
tokenizer.pad_token = tokenizer.eos_token
|
|
@@ -2144,22 +2409,43 @@ def load_model():
|
|
|
2144
2409
|
kwargs["torch_dtype"] = torch.float16
|
|
2145
2410
|
|
|
2146
2411
|
try:
|
|
2147
|
-
model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **kwargs)
|
|
2148
|
-
except Exception:
|
|
2149
2412
|
model = AutoModelForImageTextToText.from_pretrained(MODEL_PATH, **kwargs)
|
|
2413
|
+
except Exception:
|
|
2414
|
+
model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **kwargs)
|
|
2150
2415
|
if device != "cuda":
|
|
2151
2416
|
model.to(device)
|
|
2152
2417
|
model.eval()
|
|
2153
|
-
return torch, tokenizer, model, device
|
|
2418
|
+
return torch, processor, tokenizer, model, device
|
|
2154
2419
|
|
|
2155
2420
|
|
|
2156
|
-
torch, tokenizer, model, DEVICE = load_model()
|
|
2421
|
+
torch, processor, tokenizer, model, DEVICE = load_model()
|
|
2157
2422
|
|
|
2158
2423
|
|
|
2159
|
-
def
|
|
2424
|
+
def normalize_content_part(part):
|
|
2425
|
+
if not isinstance(part, dict):
|
|
2426
|
+
return {"type": "text", "text": str(part)}
|
|
2427
|
+
part_type = part.get("type")
|
|
2428
|
+
if part_type == "text":
|
|
2429
|
+
return {"type": "text", "text": str(part.get("text", ""))}
|
|
2430
|
+
if part_type in {"image", "image_url"}:
|
|
2431
|
+
image_url = part.get("image_url")
|
|
2432
|
+
url = None
|
|
2433
|
+
if isinstance(image_url, dict):
|
|
2434
|
+
url = image_url.get("url")
|
|
2435
|
+
elif isinstance(image_url, str):
|
|
2436
|
+
url = image_url
|
|
2437
|
+
url = url or part.get("image") or part.get("data_uri") or part.get("uri") or part.get("path")
|
|
2438
|
+
if isinstance(url, str) and url.strip():
|
|
2439
|
+
return {"type": "image", "image": url.strip()}
|
|
2440
|
+
return {"type": "text", "text": json.dumps(part, ensure_ascii=False)}
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
def normalize_content(content):
|
|
2160
2444
|
if isinstance(content, str):
|
|
2161
2445
|
return content
|
|
2162
|
-
|
|
2446
|
+
if isinstance(content, list):
|
|
2447
|
+
return [normalize_content_part(part) for part in content]
|
|
2448
|
+
return str(content) if content is not None else ""
|
|
2163
2449
|
|
|
2164
2450
|
|
|
2165
2451
|
def normalize_messages(raw_messages):
|
|
@@ -2170,7 +2456,7 @@ def normalize_messages(raw_messages):
|
|
|
2170
2456
|
role = item.get("role")
|
|
2171
2457
|
if role not in {"system", "user", "assistant", "tool"}:
|
|
2172
2458
|
continue
|
|
2173
|
-
messages.append({"role": role, "content":
|
|
2459
|
+
messages.append({"role": role, "content": normalize_content(item.get("content", ""))})
|
|
2174
2460
|
|
|
2175
2461
|
if SYSTEM_PROMPT and not any(message["role"] == "system" for message in messages):
|
|
2176
2462
|
messages.insert(0, {"role": "system", "content": SYSTEM_PROMPT})
|
|
@@ -2214,7 +2500,10 @@ def with_json_contract(messages, mode, schema):
|
|
|
2214
2500
|
contracted = [dict(message) for message in messages]
|
|
2215
2501
|
for message in contracted:
|
|
2216
2502
|
if message["role"] == "system":
|
|
2217
|
-
|
|
2503
|
+
content = message.get("content", "")
|
|
2504
|
+
if not isinstance(content, str):
|
|
2505
|
+
content = json.dumps(content, ensure_ascii=False)
|
|
2506
|
+
message["content"] = (content.rstrip() + "\n\n" + contract).strip()
|
|
2218
2507
|
return contracted
|
|
2219
2508
|
|
|
2220
2509
|
contracted.insert(0, {"role": "system", "content": contract})
|
|
@@ -2233,13 +2522,71 @@ def render_prompt(messages):
|
|
|
2233
2522
|
return "\n".join(rendered)
|
|
2234
2523
|
|
|
2235
2524
|
|
|
2236
|
-
def
|
|
2237
|
-
|
|
2238
|
-
|
|
2525
|
+
def load_image(value):
|
|
2526
|
+
try:
|
|
2527
|
+
from PIL import Image
|
|
2528
|
+
except Exception as exc:
|
|
2529
|
+
raise RuntimeError(
|
|
2530
|
+
"Multimodal requests require Pillow. Install the serving runtime with "
|
|
2531
|
+
"'tt models setup-runtime --force' or install pillow in the selected Python environment."
|
|
2532
|
+
) from exc
|
|
2533
|
+
|
|
2534
|
+
if value.startswith("data:image/"):
|
|
2535
|
+
_, payload = value.split(",", 1)
|
|
2536
|
+
return Image.open(BytesIO(base64.b64decode(payload))).convert("RGB")
|
|
2537
|
+
if value.startswith("http://") or value.startswith("https://"):
|
|
2538
|
+
with urlopen(value, timeout=30) as response:
|
|
2539
|
+
return Image.open(BytesIO(response.read())).convert("RGB")
|
|
2540
|
+
return Image.open(value).convert("RGB")
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
def resolve_message_images(messages):
|
|
2544
|
+
resolved = []
|
|
2545
|
+
images = []
|
|
2546
|
+
for message in messages:
|
|
2547
|
+
content = message.get("content")
|
|
2548
|
+
if not isinstance(content, list):
|
|
2549
|
+
resolved.append(message)
|
|
2550
|
+
continue
|
|
2551
|
+
|
|
2552
|
+
parts = []
|
|
2553
|
+
for part in content:
|
|
2554
|
+
if not isinstance(part, dict) or part.get("type") != "image":
|
|
2555
|
+
parts.append(part)
|
|
2556
|
+
continue
|
|
2557
|
+
image_ref = part.get("image") or part.get("data_uri") or part.get("uri") or part.get("path")
|
|
2558
|
+
if not isinstance(image_ref, str) or not image_ref.strip():
|
|
2559
|
+
raise RuntimeError("Image content part must include image, image_url.url, data_uri, uri, or path")
|
|
2560
|
+
images.append(load_image(image_ref.strip()))
|
|
2561
|
+
parts.append({"type": "image"})
|
|
2562
|
+
resolved.append({**message, "content": parts})
|
|
2563
|
+
return resolved, images
|
|
2564
|
+
|
|
2565
|
+
|
|
2566
|
+
def move_inputs_to_device(inputs, device):
|
|
2567
|
+
moved = {}
|
|
2568
|
+
for key, value in inputs.items():
|
|
2569
|
+
moved[key] = value.to(device) if hasattr(value, "to") else value
|
|
2570
|
+
return moved
|
|
2571
|
+
|
|
2572
|
+
|
|
2573
|
+
def prepare_generation_inputs(messages):
|
|
2574
|
+
resolved_messages, images = resolve_message_images(messages)
|
|
2575
|
+
prompt = render_prompt(resolved_messages)
|
|
2239
2576
|
input_device = getattr(model, "device", None)
|
|
2240
2577
|
if input_device is None:
|
|
2241
2578
|
input_device = next(model.parameters()).device
|
|
2242
|
-
|
|
2579
|
+
if images:
|
|
2580
|
+
if processor is tokenizer:
|
|
2581
|
+
raise RuntimeError("This model artifact did not load a multimodal processor, so image inputs are not supported.")
|
|
2582
|
+
inputs = processor(text=[prompt], images=images, return_tensors="pt")
|
|
2583
|
+
else:
|
|
2584
|
+
inputs = tokenizer(prompt, return_tensors="pt")
|
|
2585
|
+
return move_inputs_to_device(inputs, input_device)
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
def generate_completion(messages, max_tokens, temperature):
|
|
2589
|
+
inputs = prepare_generation_inputs(messages)
|
|
2243
2590
|
|
|
2244
2591
|
generate_kwargs = {
|
|
2245
2592
|
**inputs,
|
|
@@ -2667,7 +3014,7 @@ function ensureServingRuntime(python, cacheDir) {
|
|
|
2667
3014
|
const check = `
|
|
2668
3015
|
import importlib.util
|
|
2669
3016
|
import json
|
|
2670
|
-
required = ["torch", "transformers", "accelerate", "safetensors"]
|
|
3017
|
+
required = ["torch", "transformers", "accelerate", "safetensors", "PIL"]
|
|
2671
3018
|
missing = [name for name in required if importlib.util.find_spec(name) is None]
|
|
2672
3019
|
print(json.dumps({"missing": missing}))
|
|
2673
3020
|
`;
|
|
@@ -3238,7 +3585,7 @@ function registerModelsCommands(parent) {
|
|
|
3238
3585
|
const venvDir = runtimeDir(cacheDir);
|
|
3239
3586
|
const venvPython = runtimePythonPath(cacheDir);
|
|
3240
3587
|
const python = pickRuntimePython(cmdOpts.python);
|
|
3241
|
-
const deps = ["torch", "transformers", "accelerate", "safetensors"];
|
|
3588
|
+
const deps = ["torch", "transformers", "accelerate", "safetensors", "pillow"];
|
|
3242
3589
|
const commands = setupRuntimeCommands(python, venvDir, deps);
|
|
3243
3590
|
if (cmdOpts.printCommand) {
|
|
3244
3591
|
const commandLines = commands.map((cmd) => cmd.map(quoteCommandPart).join(" "));
|
|
@@ -3426,6 +3773,14 @@ function registerBalanceCommands(parent) {
|
|
|
3426
3773
|
const balanceLine = lowBalance ? chalk3.red(formatCents3(balance.balance_cents)) + chalk3.dim(" (low)") : chalk3.bold.green(formatCents3(balance.balance_cents));
|
|
3427
3774
|
printDetail([
|
|
3428
3775
|
["Credits", balanceLine],
|
|
3776
|
+
[
|
|
3777
|
+
"Reserved",
|
|
3778
|
+
balance.reserved_cents > 0 ? formatCents3(balance.reserved_cents) : void 0
|
|
3779
|
+
],
|
|
3780
|
+
[
|
|
3781
|
+
"Available",
|
|
3782
|
+
balance.reserved_cents > 0 ? formatCents3(balance.available_cents) : void 0
|
|
3783
|
+
],
|
|
3429
3784
|
["Lifetime top-ups", formatCents3(balance.lifetime_topup_cents)]
|
|
3430
3785
|
]);
|
|
3431
3786
|
if (transactions && transactions.length > 0) {
|
|
@@ -3768,7 +4123,7 @@ function registerPushCommand(parent) {
|
|
|
3768
4123
|
|
|
3769
4124
|
// src/index.ts
|
|
3770
4125
|
var program = new Command();
|
|
3771
|
-
program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.
|
|
4126
|
+
program.name("tt").description("Tuned Tensor CLI \u2014 fine-tune and evaluate LLMs").version("0.4.22").option("-k, --api-key <key>", "API key (overrides stored key)").option(
|
|
3772
4127
|
"-u, --base-url <url>",
|
|
3773
4128
|
"API base URL (default: https://tunedtensor.com)"
|
|
3774
4129
|
).option("--json", "Output raw JSON").option("--no-color", "Disable colors").hook("preAction", (_thisCommand, actionCommand) => {
|