@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 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 validateDatasetFile(file) {
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 {"input": "...", "output": "..."}.
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 content_to_text(content):
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
- return json.dumps(content, ensure_ascii=False)
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": content_to_text(item.get("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
- message["content"] = (message["content"].rstrip() + "\n\n" + contract).strip()
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 generate_completion(messages, max_tokens, temperature):
2237
- prompt = render_prompt(messages)
2238
- inputs = tokenizer(prompt, return_tensors="pt")
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
- inputs = {key: value.to(input_device) for key, value in inputs.items()}
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.20").option("-k, --api-key <key>", "API key (overrides stored key)").option(
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) => {