@tuned-tensor/cli 0.4.20 → 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
@@ -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"],
@@ -959,6 +963,7 @@ Eval Results (${data._evals.length}):`);
959
963
 
960
964
  // src/commands/datasets.ts
961
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"]);
962
967
  function formatBytes(bytes) {
963
968
  if (bytes < 1024) return `${bytes} B`;
964
969
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -1012,10 +1017,85 @@ function findControlChar(value) {
1012
1017
  function formatCodepoint(code) {
1013
1018
  return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`;
1014
1019
  }
1015
- 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) {
1016
1095
  const lines = readFileSync4(file, "utf8").split(/\r?\n/);
1017
1096
  const errors = [];
1018
1097
  let rowCount = 0;
1098
+ let detectedFormat = null;
1019
1099
  for (const [index, rawLine] of lines.entries()) {
1020
1100
  const line = rawLine.trim();
1021
1101
  if (!line) continue;
@@ -1039,6 +1119,26 @@ function validateDatasetFile(file) {
1039
1119
  );
1040
1120
  continue;
1041
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
+ }
1042
1142
  if (typeof record.input !== "string") {
1043
1143
  errors.push(`Row ${rowNumber}: missing string "input" field`);
1044
1144
  }
@@ -1064,10 +1164,11 @@ function validateDatasetFile(file) {
1064
1164
  const suffix = errors.length > 5 ? `
1065
1165
  ...and ${errors.length - 5} more error(s)` : "";
1066
1166
  throw new Error(
1067
- `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.
1068
1168
  ${preview}${suffix}`
1069
1169
  );
1070
1170
  }
1171
+ return detectedFormat ?? requestedFormat ?? "jsonl";
1071
1172
  }
1072
1173
  function registerDatasetsCommands(parent) {
1073
1174
  const datasets = parent.command("datasets").description("Manage datasets");
@@ -1115,13 +1216,13 @@ function registerDatasetsCommands(parent) {
1115
1216
  }
1116
1217
  }
1117
1218
  });
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) => {
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) => {
1119
1220
  const opts = parent.opts();
1120
1221
  if (!existsSync2(file)) {
1121
1222
  printError(`File not found: ${file}`);
1122
1223
  process.exit(1);
1123
1224
  }
1124
- validateDatasetFile(file);
1225
+ const format = validateDatasetFile(file, parseDatasetFormat(cmdOpts.format));
1125
1226
  const name = cmdOpts.name || file.split("/").pop().replace(/\.jsonl?$/, "");
1126
1227
  const fileBytes = readFileSync4(file);
1127
1228
  const uploadUrl = await post(
@@ -1131,7 +1232,8 @@ function registerDatasetsCommands(parent) {
1131
1232
  description: cmdOpts.description ?? null,
1132
1233
  filename: file.split("/").pop(),
1133
1234
  size: statSync2(file).size,
1134
- contentType: "application/jsonl"
1235
+ contentType: "application/jsonl",
1236
+ format
1135
1237
  },
1136
1238
  opts
1137
1239
  );
@@ -2074,7 +2176,10 @@ import json
2074
2176
  import os
2075
2177
  import time
2076
2178
  import uuid
2179
+ import base64
2180
+ from io import BytesIO
2077
2181
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
2182
+ from urllib.request import urlopen
2078
2183
 
2079
2184
  MODEL_PATH = os.environ["TT_MODEL_PATH"]
2080
2185
  MODEL_NAME = os.environ.get("TT_MODEL_NAME", os.path.basename(MODEL_PATH.rstrip("/")) or "tuned-tensor-model")
@@ -2125,9 +2230,9 @@ def load_model():
2125
2230
  ) from exc
2126
2231
 
2127
2232
  try:
2128
- processor = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=TRUST_REMOTE_CODE)
2129
- except Exception:
2130
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)
2131
2236
  tokenizer = getattr(processor, "tokenizer", processor)
2132
2237
  if getattr(tokenizer, "pad_token", None) is None:
2133
2238
  tokenizer.pad_token = tokenizer.eos_token
@@ -2144,22 +2249,43 @@ def load_model():
2144
2249
  kwargs["torch_dtype"] = torch.float16
2145
2250
 
2146
2251
  try:
2147
- model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **kwargs)
2148
- except Exception:
2149
2252
  model = AutoModelForImageTextToText.from_pretrained(MODEL_PATH, **kwargs)
2253
+ except Exception:
2254
+ model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **kwargs)
2150
2255
  if device != "cuda":
2151
2256
  model.to(device)
2152
2257
  model.eval()
2153
- return torch, tokenizer, model, device
2258
+ return torch, processor, tokenizer, model, device
2259
+
2260
+
2261
+ torch, processor, tokenizer, model, DEVICE = load_model()
2154
2262
 
2155
2263
 
2156
- 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)}
2157
2281
 
2158
2282
 
2159
- def content_to_text(content):
2283
+ def normalize_content(content):
2160
2284
  if isinstance(content, str):
2161
2285
  return content
2162
- 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 ""
2163
2289
 
2164
2290
 
2165
2291
  def normalize_messages(raw_messages):
@@ -2170,7 +2296,7 @@ def normalize_messages(raw_messages):
2170
2296
  role = item.get("role")
2171
2297
  if role not in {"system", "user", "assistant", "tool"}:
2172
2298
  continue
2173
- messages.append({"role": role, "content": content_to_text(item.get("content", ""))})
2299
+ messages.append({"role": role, "content": normalize_content(item.get("content", ""))})
2174
2300
 
2175
2301
  if SYSTEM_PROMPT and not any(message["role"] == "system" for message in messages):
2176
2302
  messages.insert(0, {"role": "system", "content": SYSTEM_PROMPT})
@@ -2214,7 +2340,10 @@ def with_json_contract(messages, mode, schema):
2214
2340
  contracted = [dict(message) for message in messages]
2215
2341
  for message in contracted:
2216
2342
  if message["role"] == "system":
2217
- 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()
2218
2347
  return contracted
2219
2348
 
2220
2349
  contracted.insert(0, {"role": "system", "content": contract})
@@ -2233,13 +2362,71 @@ def render_prompt(messages):
2233
2362
  return "\n".join(rendered)
2234
2363
 
2235
2364
 
2236
- def generate_completion(messages, max_tokens, temperature):
2237
- prompt = render_prompt(messages)
2238
- 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)
2239
2416
  input_device = getattr(model, "device", None)
2240
2417
  if input_device is None:
2241
2418
  input_device = next(model.parameters()).device
2242
- 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)
2243
2430
 
2244
2431
  generate_kwargs = {
2245
2432
  **inputs,
@@ -2667,7 +2854,7 @@ function ensureServingRuntime(python, cacheDir) {
2667
2854
  const check = `
2668
2855
  import importlib.util
2669
2856
  import json
2670
- required = ["torch", "transformers", "accelerate", "safetensors"]
2857
+ required = ["torch", "transformers", "accelerate", "safetensors", "PIL"]
2671
2858
  missing = [name for name in required if importlib.util.find_spec(name) is None]
2672
2859
  print(json.dumps({"missing": missing}))
2673
2860
  `;
@@ -3238,7 +3425,7 @@ function registerModelsCommands(parent) {
3238
3425
  const venvDir = runtimeDir(cacheDir);
3239
3426
  const venvPython = runtimePythonPath(cacheDir);
3240
3427
  const python = pickRuntimePython(cmdOpts.python);
3241
- const deps = ["torch", "transformers", "accelerate", "safetensors"];
3428
+ const deps = ["torch", "transformers", "accelerate", "safetensors", "pillow"];
3242
3429
  const commands = setupRuntimeCommands(python, venvDir, deps);
3243
3430
  if (cmdOpts.printCommand) {
3244
3431
  const commandLines = commands.map((cmd) => cmd.map(quoteCommandPart).join(" "));
@@ -3426,6 +3613,14 @@ function registerBalanceCommands(parent) {
3426
3613
  const balanceLine = lowBalance ? chalk3.red(formatCents3(balance.balance_cents)) + chalk3.dim(" (low)") : chalk3.bold.green(formatCents3(balance.balance_cents));
3427
3614
  printDetail([
3428
3615
  ["Credits", balanceLine],
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
+ ],
3429
3624
  ["Lifetime top-ups", formatCents3(balance.lifetime_topup_cents)]
3430
3625
  ]);
3431
3626
  if (transactions && transactions.length > 0) {
@@ -3768,7 +3963,7 @@ function registerPushCommand(parent) {
3768
3963
 
3769
3964
  // src/index.ts
3770
3965
  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(
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(
3772
3967
  "-u, --base-url <url>",
3773
3968
  "API base URL (default: https://tunedtensor.com)"
3774
3969
  ).option("--json", "Output raw JSON").option("--no-color", "Disable colors").hook("preAction", (_thisCommand, actionCommand) => {