@tuned-tensor/local 0.2.7 → 0.2.9

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +147 -2
  3. package/dist/artifacts.d.ts +92 -0
  4. package/dist/artifacts.js +591 -3
  5. package/dist/artifacts.js.map +1 -1
  6. package/dist/contracts.d.ts +5 -2
  7. package/dist/contracts.js +15 -10
  8. package/dist/contracts.js.map +1 -1
  9. package/dist/dataset.d.ts +3 -0
  10. package/dist/dataset.js +87 -5
  11. package/dist/dataset.js.map +1 -1
  12. package/dist/doctor.d.ts +13 -2
  13. package/dist/doctor.js +372 -49
  14. package/dist/doctor.js.map +1 -1
  15. package/dist/evaluation.d.ts +15 -0
  16. package/dist/evaluation.js +120 -16
  17. package/dist/evaluation.js.map +1 -1
  18. package/dist/huggingface-cache.d.ts +26 -0
  19. package/dist/huggingface-cache.js +68 -0
  20. package/dist/huggingface-cache.js.map +1 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +742 -76
  23. package/dist/index.js.map +1 -1
  24. package/dist/local-project.d.ts +11 -0
  25. package/dist/local-project.js +96 -3
  26. package/dist/local-project.js.map +1 -1
  27. package/dist/model-registry.d.ts +21 -0
  28. package/dist/model-registry.js +198 -0
  29. package/dist/model-registry.js.map +1 -1
  30. package/dist/model-server.d.ts +32 -0
  31. package/dist/model-server.js +158 -0
  32. package/dist/model-server.js.map +1 -0
  33. package/dist/orchestrator.d.ts +3 -0
  34. package/dist/orchestrator.js +1001 -142
  35. package/dist/orchestrator.js.map +1 -1
  36. package/dist/prefetch.d.ts +13 -0
  37. package/dist/prefetch.js +107 -19
  38. package/dist/prefetch.js.map +1 -1
  39. package/dist/process-runner.d.ts +7 -0
  40. package/dist/process-runner.js +171 -16
  41. package/dist/process-runner.js.map +1 -1
  42. package/dist/process-training.d.ts +5 -1
  43. package/dist/process-training.js +32 -16
  44. package/dist/process-training.js.map +1 -1
  45. package/dist/run-reporter.d.ts +2 -0
  46. package/dist/run-reporter.js +10 -0
  47. package/dist/run-reporter.js.map +1 -1
  48. package/dist/store.d.ts +16 -2
  49. package/dist/store.js +189 -24
  50. package/dist/store.js.map +1 -1
  51. package/docs/architecture.md +32 -0
  52. package/docs/local-workflow-remediation-2026-07-13.md +130 -0
  53. package/docs/local-workflow-ux-review-2026-07-13.md +458 -0
  54. package/package.json +4 -2
  55. package/training/local-runner/src/evaluate.py +80 -20
  56. package/training/local-runner/src/prefetch.py +107 -8
  57. package/training/local-runner/src/serve.py +329 -0
  58. package/training/local-runner/src/train.py +47 -22
  59. package/training/local-runner/src/train_dpo.py +41 -25
  60. package/training/local-runner/uv.lock +2401 -0
package/dist/index.js CHANGED
@@ -1,26 +1,32 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
4
- import { resolve } from "node:path";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { mkdir, open, readFile, stat } from "node:fs/promises";
5
+ import { spawn } from "node:child_process";
6
+ import { basename, dirname, join, resolve } from "node:path";
5
7
  import { cwd } from "node:process";
6
8
  import { fileURLToPath } from "node:url";
7
9
  import { compareRuns } from "./compare.js";
10
+ import { assertArtifactManifest, claimRunArtifactDirectory, defaultArtifactPrefix, resolveRunArtifacts } from "./artifacts.js";
8
11
  import { fineTuneRunRequestSchema, localBehaviorSpecFileSchema, localRunnerConfigSchema, specSnapshotSchema } from "./contracts.js";
9
12
  import { buildSystemMessage } from "./dataset.js";
13
+ import { assertEvaluationScoringReady } from "./evaluation.js";
10
14
  import { runLocalLabelingJob } from "./labeling.js";
11
- import { loadLocalRunnerConfig, runLocalFineTuneStage, } from "./orchestrator.js";
15
+ import { loadLocalRunnerConfig, fingerprintLocalBaseModel, runLocalFineTuneStage, } from "./orchestrator.js";
12
16
  import { runDoctor } from "./doctor.js";
13
- import { resolveTrainingModel } from "./model-registry.js";
17
+ import { assertUsableModelArtifact, resolveTrainingModel } from "./model-registry.js";
18
+ import { buildLocalModelServerLaunch, serveLocalModel } from "./model-server.js";
14
19
  import { prefetchBaseModel } from "./prefetch.js";
15
- import { createLocalStore } from "./store.js";
20
+ import { createLocalStore, isTerminalRunState } from "./store.js";
16
21
  import { serveLocalDashboard } from "./server.js";
17
- import { DEFAULT_LOCAL_SPEC_PATH, initLocalSpecFile, loadLocalRunInput, runRequestFromLocalSpec, } from "./local-project.js";
22
+ import { DEFAULT_LOCAL_SPEC_PATH, assertLocalRunInputReady, initLocalRunnerConfigFile, initLocalSpecFile, loadLocalRunInput, runRequestFromLocalSpec, } from "./local-project.js";
18
23
  import { sanitizeLogLine } from "./run-reporter.js";
19
24
  export * from "./compare.js";
20
25
  export * from "./contracts.js";
21
26
  export * from "./dataset.js";
22
27
  export * from "./labeling.js";
23
28
  export * from "./labeling-sanitize.js";
29
+ export * from "./model-server.js";
24
30
  export * from "./orchestrator.js";
25
31
  export * from "./local-project.js";
26
32
  export * from "./openrouter.js";
@@ -28,11 +34,22 @@ export * from "./prefetch.js";
28
34
  export * from "./run-reporter.js";
29
35
  export * from "./server.js";
30
36
  export * from "./store.js";
37
+ function packageVersion() {
38
+ try {
39
+ const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
40
+ return typeof packageJson.version === "string" ? packageJson.version : "unknown";
41
+ }
42
+ catch {
43
+ return "unknown";
44
+ }
45
+ }
46
+ export const TT_LOCAL_VERSION = packageVersion();
31
47
  export function getLocalRunnerInfo() {
32
48
  return {
33
49
  name: "tuned-tensor-local",
34
50
  status: "local-runner-preview",
35
- description: "Local fine-tuning runner for single-GPU uv/Python hosts.",
51
+ description: "Local-first fine-tuning with baseline-vs-tuned evaluation for small open-weight models.",
52
+ version: TT_LOCAL_VERSION,
36
53
  };
37
54
  }
38
55
  /**
@@ -81,72 +98,343 @@ async function loadDotEnvFromCwd() {
81
98
  }
82
99
  function readOption(argv, name) {
83
100
  const index = argv.indexOf(name);
84
- if (index === -1)
85
- return undefined;
86
- return argv[index + 1];
101
+ if (index !== -1)
102
+ return argv[index + 1];
103
+ const inline = argv.find((value) => value.startsWith(`${name}=`));
104
+ return inline?.slice(name.length + 1);
87
105
  }
88
106
  function hasFlag(argv, name) {
89
107
  return argv.includes(name);
90
108
  }
91
109
  function printHelp() {
92
- console.log(`tt-local
110
+ console.log(`Usage: tt-local <command> [options]
93
111
 
94
112
  Commands:
95
- info
96
- init [--name "My Local Model"] [--model Qwen/Qwen3.5-2B] [--output tunedtensor.json] [--force]
97
- doctor [--config local-runner.json]
113
+ info Show package and runner information
114
+ init [--name "My Local Model"] [--model Qwen/Qwen3.5-2B] [--output tunedtensor.json] [--profile spark] [--force]
115
+ doctor [tunedtensor.json|request.json] [--config local-runner.json]
98
116
  validate [tunedtensor.json|request.json] [--config local-runner.json]
99
117
  run [tunedtensor.json|request.json] [--config local-runner.json] [--stage all|prepare|baseline|train|candidate|score|report]
100
118
  [--run-id uuid] [--parent-model local-model-id] [--parent-model-artifact path-or-file-uri]
101
- [--model-artifact path-or-file-uri] [--force] [--dry-run] [--verbose] [--quiet]
119
+ [--model-artifact path-or-file-uri] [--force] [--dry-run] [--detach] [--verbose] [--quiet]
102
120
  label <input.jsonl|input.csv> [--input-column col] [--spec tunedtensor.json] [--system-prompt "..."]
103
121
  [--model teacher-model] [--output labeled.jsonl] [--config local-runner.json] [--dry-run]
104
122
  serve [--config local-runner.json] [--host 127.0.0.1] [--port 8787]
105
123
  runs list|get|events|watch|report|compare|cancel|reconcile [args] [--config local-runner.json]
106
- models list|get|prefetch [args] [--config local-runner.json]
124
+ models list|get|verify|prefetch|verify-base|serve [args] [--config local-runner.json]
107
125
  specs list|get|import [args] [--config local-runner.json]
108
126
  store rebuild-index [--config local-runner.json]
109
127
 
128
+ Global options:
129
+ -h, --help Show help
130
+ -V, --version Show the installed version
131
+
110
132
  The run command writes local artifacts under config.artifactRoot, defaulting to
111
133
  .tt-local/artifacts. The file-backed local store defaults to
112
134
  ~/.tuned-tensor-local unless config.storeRoot or TT_LOCAL_HOME is set.`);
113
135
  }
114
- const optionNamesWithValues = new Set([
115
- "--config",
116
- "--host",
117
- "--port",
118
- "--name",
119
- "--model",
120
- "--output",
121
- "--id",
122
- "--user-id",
123
- "--run-number",
124
- "--run-id",
125
- "--stage",
126
- "--model-artifact",
127
- "--parent-model",
128
- "--parent-model-artifact",
129
- "--input-column",
130
- "--system-prompt",
131
- "--spec",
132
- "--format",
133
- ]);
134
- function readPositionals(argv, startIndex = 3) {
136
+ const CONFIG_OPTION = { name: "--config", value: "path", description: "Local runner config JSON path" };
137
+ const USER_ID_OPTION = { name: "--user-id", value: "id", description: "Override the local user ID" };
138
+ const RUN_NUMBER_OPTION = { name: "--run-number", value: "number", description: "Override the local run number" };
139
+ const VERBOSE_OPTION = { name: "--verbose", description: "Stream subprocess output" };
140
+ const QUIET_OPTION = { name: "--quiet", description: "Suppress progress output on stderr" };
141
+ const COMMAND_DEFINITIONS = {
142
+ info: {
143
+ usage: "tt-local info",
144
+ description: "Show the installed TT Local version and runner status.",
145
+ options: [],
146
+ maxPositionals: 0,
147
+ },
148
+ init: {
149
+ usage: "tt-local init [options]",
150
+ description: "Create a local tunedtensor.json behavior spec.",
151
+ options: [
152
+ { name: "--name", value: "name", description: "Behavior spec name" },
153
+ { name: "--model", value: "model", description: "Base model ID" },
154
+ { name: "--output", value: "path", description: "Output spec path" },
155
+ { name: "--profile", value: "profile", description: "Write a durable runner config (spark)" },
156
+ { name: "--config-output", value: "path", description: "Profile config output path" },
157
+ { name: "--force", description: "Overwrite an existing output file" },
158
+ ],
159
+ maxPositionals: 0,
160
+ },
161
+ doctor: {
162
+ usage: "tt-local doctor [tunedtensor.json|request.json] [--config path]",
163
+ description: "Check the host and optional run input before starting work.",
164
+ options: [CONFIG_OPTION],
165
+ maxPositionals: 1,
166
+ },
167
+ validate: {
168
+ usage: "tt-local validate [tunedtensor.json|request.json] [options]",
169
+ description: "Validate a behavior spec or run request without executing it.",
170
+ options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION],
171
+ maxPositionals: 1,
172
+ },
173
+ run: {
174
+ usage: "tt-local run [tunedtensor.json|request.json] [options]",
175
+ description: "Run the baseline, fine-tuning, tuned evaluation, and report workflow.",
176
+ options: [
177
+ CONFIG_OPTION,
178
+ USER_ID_OPTION,
179
+ RUN_NUMBER_OPTION,
180
+ { name: "--run-id", value: "uuid", description: "Resume or override a run ID" },
181
+ { name: "--stage", value: "stage", description: "Run through a specific workflow stage" },
182
+ { name: "--model-artifact", value: "uri", description: "Use an existing model artifact" },
183
+ { name: "--parent-model", value: "model-id", description: "Continue training a stored local model" },
184
+ { name: "--parent-model-artifact", value: "uri", description: "Continue training a model artifact" },
185
+ { name: "--force", description: "Recompute the selected stage" },
186
+ { name: "--dry-run", description: "Write representative artifacts without GPU work" },
187
+ { name: "--detach", description: "Run in the background and return the run ID immediately" },
188
+ VERBOSE_OPTION,
189
+ QUIET_OPTION,
190
+ ],
191
+ maxPositionals: 1,
192
+ },
193
+ label: {
194
+ usage: "tt-local label <input.jsonl|input.csv> [options]",
195
+ description: "Label local rows with a teacher model.",
196
+ options: [
197
+ CONFIG_OPTION,
198
+ { name: "--input-column", value: "column", description: "CSV input column" },
199
+ { name: "--spec", value: "path", description: "Behavior spec path" },
200
+ { name: "--system-prompt", value: "text", description: "Explicit teacher system prompt" },
201
+ { name: "--model", value: "model", description: "Teacher model ID" },
202
+ { name: "--output", value: "path", description: "Output JSONL path" },
203
+ { name: "--format", value: "jsonl|csv", description: "Force the source format" },
204
+ { name: "--dry-run", description: "Parse and sanitize without teacher calls" },
205
+ VERBOSE_OPTION,
206
+ QUIET_OPTION,
207
+ ],
208
+ minPositionals: 1,
209
+ maxPositionals: 1,
210
+ missingPositionalsMessage: "label requires <input.jsonl|input.csv>",
211
+ },
212
+ serve: {
213
+ usage: "tt-local serve [--config path] [--host host] [--port port]",
214
+ description: "Serve the local runs dashboard.",
215
+ options: [
216
+ CONFIG_OPTION,
217
+ { name: "--host", value: "host", description: "Dashboard bind host" },
218
+ { name: "--port", value: "port", description: "Dashboard bind port" },
219
+ ],
220
+ maxPositionals: 0,
221
+ },
222
+ };
223
+ const COMMAND_GROUPS = {
224
+ runs: {
225
+ description: "Inspect and manage locally stored runs.",
226
+ defaultSubcommand: "list",
227
+ subcommands: {
228
+ list: { usage: "tt-local runs list [--config path]", description: "List local runs.", options: [CONFIG_OPTION], maxPositionals: 0 },
229
+ get: { usage: "tt-local runs get <run-id> [--config path]", description: "Get a local run.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs get requires <run-id>" },
230
+ events: { usage: "tt-local runs events <run-id> [--config path]", description: "List run events.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs events requires <run-id>" },
231
+ watch: { usage: "tt-local runs watch <run-id> [--config path]", description: "Watch a run until it finishes.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs watch requires <run-id>" },
232
+ report: { usage: "tt-local runs report <run-id> [--config path]", description: "Show the baseline-vs-tuned report, including deltas and regressions.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs report requires <run-id>" },
233
+ compare: { usage: "tt-local runs compare <run-id-a> <run-id-b> [--config path]", description: "Compare two run reports.", options: [CONFIG_OPTION], minPositionals: 2, maxPositionals: 2, missingPositionalsMessage: "runs compare requires <run-id-a> <run-id-b>" },
234
+ cancel: { usage: "tt-local runs cancel <run-id> [--config path]", description: "Request cancellation of a run.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs cancel requires <run-id>" },
235
+ reconcile: { usage: "tt-local runs reconcile [--config path]", description: "Rebuild local store indexes.", options: [CONFIG_OPTION], maxPositionals: 0 },
236
+ },
237
+ },
238
+ models: {
239
+ description: "Inspect, verify, prefetch, or serve local models.",
240
+ defaultSubcommand: "list",
241
+ subcommands: {
242
+ list: { usage: "tt-local models list [--config path]", description: "List local models.", options: [CONFIG_OPTION], maxPositionals: 0 },
243
+ get: { usage: "tt-local models get <model-id> [--config path]", description: "Get a local model.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "models get requires <model-id>" },
244
+ verify: {
245
+ usage: "tt-local models verify <model-id-or-artifact-path> [--config path]",
246
+ description: "Verify a stored model or manifested artifact path.",
247
+ options: [CONFIG_OPTION],
248
+ minPositionals: 1,
249
+ maxPositionals: 1,
250
+ missingPositionalsMessage: "models verify requires <model-id-or-artifact-path>",
251
+ },
252
+ prefetch: {
253
+ usage: "tt-local models prefetch [tunedtensor.json|request.json] [options]",
254
+ description: "Download the configured base model before a run.",
255
+ options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, VERBOSE_OPTION, QUIET_OPTION],
256
+ maxPositionals: 1,
257
+ },
258
+ "verify-base": {
259
+ usage: "tt-local models verify-base [tunedtensor.json|request.json] [options]",
260
+ description: "Verify that the configured base-model snapshot is complete and locally available.",
261
+ options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, VERBOSE_OPTION, QUIET_OPTION],
262
+ maxPositionals: 1,
263
+ },
264
+ serve: {
265
+ usage: "tt-local models serve <model-id> [options]",
266
+ description: "Serve a verified adapter through an OpenAI-compatible local API.",
267
+ options: [
268
+ CONFIG_OPTION,
269
+ { name: "--host", value: "host", description: "Bind host (localhost by default)" },
270
+ { name: "--port", value: "port", description: "Bind port" },
271
+ { name: "--device", value: "device", description: "auto, cpu, cuda, or mps" },
272
+ { name: "--max-tokens", value: "count", description: "Default response token limit" },
273
+ { name: "--temperature", value: "number", description: "Default sampling temperature" },
274
+ { name: "--top-p", value: "number", description: "Default nucleus sampling threshold" },
275
+ { name: "--max-concurrent-requests", value: "count", description: "Concurrent generation limit" },
276
+ { name: "--spec", value: "path", description: "Behavior spec whose instructions are enforced" },
277
+ { name: "--no-spec-prompt", description: "Do not enforce the stored behavior-spec prompt" },
278
+ { name: "--allow-remote", description: "Allow a non-loopback bind" },
279
+ { name: "--api-key-env", value: "name", description: "Environment variable containing a bearer token" },
280
+ { name: "--print-command", description: "Validate and print the launch plan without starting" },
281
+ ],
282
+ minPositionals: 1,
283
+ maxPositionals: 1,
284
+ missingPositionalsMessage: "models serve requires <model-id>",
285
+ },
286
+ },
287
+ },
288
+ specs: {
289
+ description: "Inspect and import local behavior specs.",
290
+ defaultSubcommand: "list",
291
+ subcommands: {
292
+ list: { usage: "tt-local specs list [--config path]", description: "List local behavior specs.", options: [CONFIG_OPTION], maxPositionals: 0 },
293
+ get: { usage: "tt-local specs get <spec-id> [--config path]", description: "Get a local behavior spec.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "specs get requires <spec-id>" },
294
+ import: {
295
+ usage: "tt-local specs import <spec-or-request.json> [options]",
296
+ description: "Import a behavior spec into the local store.",
297
+ options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, { name: "--id", value: "id", description: "ID for a raw spec snapshot" }],
298
+ minPositionals: 1,
299
+ maxPositionals: 1,
300
+ missingPositionalsMessage: "specs import requires <spec-or-request.json>",
301
+ },
302
+ },
303
+ },
304
+ store: {
305
+ description: "Maintain the local file-backed store.",
306
+ subcommands: {
307
+ "rebuild-index": { usage: "tt-local store rebuild-index [--config path]", description: "Rebuild store indexes from durable records.", options: [CONFIG_OPTION], maxPositionals: 0 },
308
+ },
309
+ },
310
+ };
311
+ function hasHelpFlag(argv) {
312
+ return argv.includes("--help") || argv.includes("-h");
313
+ }
314
+ function printCommandHelp(definition) {
315
+ console.log(`Usage: ${definition.usage}\n\n${definition.description}`);
316
+ if (definition.options.length > 0) {
317
+ console.log("\nOptions:");
318
+ for (const option of definition.options) {
319
+ const label = option.value ? `${option.name} <${option.value}>` : option.name;
320
+ console.log(` ${label.padEnd(34)} ${option.description}`);
321
+ }
322
+ }
323
+ console.log(" -h, --help Show help");
324
+ }
325
+ function printGroupHelp(command, group) {
326
+ console.log(`Usage: tt-local ${command} <command> [options]\n\n${group.description}\n\nCommands:`);
327
+ for (const [name, definition] of Object.entries(group.subcommands)) {
328
+ console.log(` ${name.padEnd(16)} ${definition.description}`);
329
+ }
330
+ console.log("\nRun `tt-local " + command + " <command> --help` for command-specific help.");
331
+ }
332
+ function parseCommandArguments(tokens, definition) {
333
+ const options = new Map(definition.options.map((option) => [option.name, option]));
334
+ const seen = new Set();
135
335
  const positionals = [];
136
- for (let index = startIndex; index < argv.length; index += 1) {
137
- const arg = argv[index];
138
- if (!arg)
139
- continue;
140
- if (optionNamesWithValues.has(arg)) {
141
- index += 1;
336
+ let optionsEnded = false;
337
+ for (let index = 0; index < tokens.length; index += 1) {
338
+ const token = tokens[index];
339
+ if (!optionsEnded && token === "--") {
340
+ optionsEnded = true;
142
341
  continue;
143
342
  }
144
- if (arg.startsWith("--"))
343
+ if (!optionsEnded && token.startsWith("-")) {
344
+ const equalsIndex = token.indexOf("=");
345
+ const name = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
346
+ const inlineValue = equalsIndex === -1 ? undefined : token.slice(equalsIndex + 1);
347
+ const option = options.get(name);
348
+ if (!option)
349
+ throw new Error(`Unknown option: ${name}`);
350
+ if (seen.has(name))
351
+ throw new Error(`Option ${name} may only be specified once.`);
352
+ seen.add(name);
353
+ if (option.value) {
354
+ if (inlineValue !== undefined) {
355
+ if (!inlineValue)
356
+ throw new Error(`Option ${name} requires a value.`);
357
+ continue;
358
+ }
359
+ const value = tokens[index + 1];
360
+ if (value === undefined || value.startsWith("-")) {
361
+ throw new Error(`Option ${name} requires a value.`);
362
+ }
363
+ index += 1;
364
+ }
365
+ else if (inlineValue !== undefined) {
366
+ throw new Error(`Option ${name} does not accept a value.`);
367
+ }
145
368
  continue;
146
- positionals.push(arg);
369
+ }
370
+ positionals.push(token);
371
+ }
372
+ if (positionals.length < (definition.minPositionals ?? 0)) {
373
+ throw new Error(definition.missingPositionalsMessage ?? `Missing required argument. Usage: ${definition.usage}`);
374
+ }
375
+ if (definition.maxPositionals !== undefined && positionals.length > definition.maxPositionals) {
376
+ throw new Error(`Too many arguments. Usage: ${definition.usage}`);
147
377
  }
148
378
  return positionals;
149
379
  }
380
+ function parseCli(argv) {
381
+ const command = argv[2] ?? "info";
382
+ if (command === "--help" || command === "-h") {
383
+ return { command: "info", positionals: [], help: "top" };
384
+ }
385
+ if (command === "--version" || command === "-V") {
386
+ if (argv.length > 3)
387
+ throw new Error(`${command} does not accept arguments.`);
388
+ return { command, positionals: [], help: "command" };
389
+ }
390
+ if (command.startsWith("-"))
391
+ throw new Error(`Unknown option: ${command}`);
392
+ const definition = COMMAND_DEFINITIONS[command];
393
+ if (definition) {
394
+ if (hasHelpFlag(argv.slice(3))) {
395
+ return { command, positionals: [], help: "command", definition };
396
+ }
397
+ return {
398
+ command,
399
+ positionals: parseCommandArguments(argv.slice(3), definition),
400
+ help: "top",
401
+ definition,
402
+ };
403
+ }
404
+ const group = COMMAND_GROUPS[command];
405
+ if (!group)
406
+ throw new Error(`Unknown command: ${command}`);
407
+ if (argv[3] === "--help" || argv[3] === "-h") {
408
+ return { command, positionals: [], help: "group" };
409
+ }
410
+ const candidate = argv[3];
411
+ let subcommand;
412
+ let tokenStart;
413
+ if (candidate && !candidate.startsWith("-")) {
414
+ subcommand = candidate;
415
+ tokenStart = 4;
416
+ }
417
+ else if (group.defaultSubcommand) {
418
+ subcommand = group.defaultSubcommand;
419
+ tokenStart = 3;
420
+ }
421
+ else {
422
+ throw new Error(`${command} requires a subcommand. Run 'tt-local ${command} --help'.`);
423
+ }
424
+ const subcommandDefinition = group.subcommands[subcommand];
425
+ if (!subcommandDefinition)
426
+ throw new Error(`Unknown ${command} command: ${subcommand}`);
427
+ if (hasHelpFlag(argv.slice(tokenStart))) {
428
+ return { command, subcommand, positionals: [], help: "command", definition: subcommandDefinition };
429
+ }
430
+ return {
431
+ command,
432
+ subcommand,
433
+ positionals: parseCommandArguments(argv.slice(tokenStart), subcommandDefinition),
434
+ help: "top",
435
+ definition: subcommandDefinition,
436
+ };
437
+ }
150
438
  function readNumberOption(argv, name) {
151
439
  const value = readOption(argv, name);
152
440
  return value ? Number(value) : undefined;
@@ -235,10 +523,45 @@ async function parentModelSelectionFromArgv(argv, config) {
235
523
  return undefined;
236
524
  const store = createLocalStore(config.storeRoot);
237
525
  const record = await store.getModel(parentModel);
526
+ const verified = await verifyStoredModel(record, { requireServable: true });
527
+ const contract = verified.contract;
528
+ const baseModelRevision = typeof contract.base_model_revision === "string"
529
+ ? contract.base_model_revision
530
+ : undefined;
531
+ const baseModelArtifactUri = typeof contract.base_model_artifact_uri === "string"
532
+ ? contract.base_model_artifact_uri
533
+ : undefined;
534
+ const baseModelFingerprint = typeof contract.base_model_fingerprint === "string"
535
+ ? contract.base_model_fingerprint
536
+ : undefined;
537
+ if (baseModelArtifactUri && baseModelFingerprint) {
538
+ if (!config.paths.baseModel) {
539
+ throw new Error(`Parent model ${record.id} was trained from a local base snapshot. Configure paths.baseModel to the same `
540
+ + "snapshot before continuing it.");
541
+ }
542
+ const configuredFingerprint = await fingerprintLocalBaseModel(config.paths.baseModel);
543
+ if (configuredFingerprint !== baseModelFingerprint) {
544
+ throw new Error(`Configured paths.baseModel content does not match parent model ${record.id}.`);
545
+ }
546
+ }
547
+ else if (!baseModelRevision) {
548
+ throw new Error(`Parent model ${record.id} does not record an immutable base-model revision or local snapshot fingerprint. `
549
+ + "Use --parent-model-artifact only if you intentionally accept responsibility for that unpinned artifact.");
550
+ }
551
+ if (baseModelRevision && config.paths.baseModel) {
552
+ const snapshotRevision = resolve(config.paths.baseModel).match(/[\\/]snapshots[\\/]([^\\/]+)(?:[\\/]|$)/)?.[1];
553
+ if (!baseModelFingerprint && snapshotRevision !== baseModelRevision) {
554
+ throw new Error(`Parent model ${record.id} requires base revision ${baseModelRevision}; configured paths.baseModel does not `
555
+ + "identify that snapshot revision.");
556
+ }
557
+ }
238
558
  return {
239
559
  artifactUri: record.artifact_uri,
240
560
  modelId: record.id,
241
561
  baseModel: record.base_model,
562
+ baseModelRevision,
563
+ baseModelArtifactUri,
564
+ baseModelFingerprint,
242
565
  };
243
566
  }
244
567
  function withParentModelArtifact(request, parentModel) {
@@ -248,10 +571,16 @@ function withParentModelArtifact(request, parentModel) {
248
571
  throw new Error(`Parent model ${parentModel.modelId ?? ""} uses base model ${parentModel.baseModel}, `
249
572
  + `but this run uses ${request.spec_snapshot.base_model}. Continue from a model with the same base model.`);
250
573
  }
574
+ const requestedRevision = request.hyperparameters.base_model_revision;
575
+ if (parentModel.baseModelRevision && requestedRevision && parentModel.baseModelRevision !== requestedRevision) {
576
+ throw new Error(`Parent model ${parentModel.modelId ?? ""} uses base revision ${parentModel.baseModelRevision}, `
577
+ + `but this run requests ${requestedRevision}.`);
578
+ }
251
579
  return fineTuneRunRequestSchema.parse({
252
580
  ...request,
253
581
  hyperparameters: {
254
582
  ...request.hyperparameters,
583
+ ...(parentModel.baseModelRevision ? { base_model_revision: parentModel.baseModelRevision } : {}),
255
584
  parent_model_artifact: parentModel.artifactUri,
256
585
  },
257
586
  });
@@ -259,26 +588,265 @@ function withParentModelArtifact(request, parentModel) {
259
588
  async function sleep(ms) {
260
589
  await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
261
590
  }
262
- function isTerminalStatus(status) {
263
- return status === "completed" || status === "failed" || status === "cancelled";
591
+ function detachedRunArgv(argv, runId) {
592
+ const tokens = argv.slice(3);
593
+ const output = [];
594
+ for (let index = 0; index < tokens.length; index += 1) {
595
+ const token = tokens[index];
596
+ if (token === "--detach")
597
+ continue;
598
+ if (token === "--run-id") {
599
+ index += 1;
600
+ continue;
601
+ }
602
+ if (token.startsWith("--run-id="))
603
+ continue;
604
+ output.push(token);
605
+ }
606
+ return ["run", ...output, "--run-id", runId];
607
+ }
608
+ async function launchDetachedRun(args) {
609
+ if (!process.argv[1])
610
+ throw new Error("Cannot determine the tt-local CLI entrypoint for --detach.");
611
+ const prefix = args.request.artifacts?.prefix ?? defaultArtifactPrefix({
612
+ userId: args.request.user_id,
613
+ behaviorSpecId: args.request.behavior_spec_id,
614
+ runId: args.request.run_id,
615
+ });
616
+ const artifacts = resolveRunArtifacts({ artifactRoot: args.config.artifactRoot, prefix });
617
+ await claimRunArtifactDirectory({
618
+ artifacts,
619
+ runId: args.request.run_id,
620
+ userId: args.request.user_id,
621
+ behaviorSpecId: args.request.behavior_spec_id,
622
+ });
623
+ const store = createLocalStore(args.config.storeRoot);
624
+ const existing = await store.getRun(args.request.run_id).catch(() => null);
625
+ if (existing) {
626
+ await store.syncRunRequest(args.request);
627
+ }
628
+ else {
629
+ await store.startRun({ request: args.request, artifactDir: artifacts.runDir });
630
+ }
631
+ const logPath = resolve(artifacts.runDir, "detached.log");
632
+ await mkdir(dirname(logPath), { recursive: true });
633
+ const log = await open(logPath, "a");
634
+ try {
635
+ const child = spawn(process.execPath, [
636
+ ...process.execArgv,
637
+ process.argv[1],
638
+ ...detachedRunArgv(args.argv, args.request.run_id),
639
+ ], {
640
+ detached: true,
641
+ stdio: ["ignore", log.fd, log.fd],
642
+ env: { ...process.env, TT_LOCAL_DETACHED: "1" },
643
+ });
644
+ await new Promise((resolveSpawn, reject) => {
645
+ child.once("spawn", resolveSpawn);
646
+ child.once("error", reject);
647
+ });
648
+ if (!child.pid)
649
+ throw new Error("Detached tt-local process did not report a PID.");
650
+ child.unref();
651
+ return { pid: child.pid, logPath, artifactDir: artifacts.runDir };
652
+ }
653
+ catch (error) {
654
+ await store.failRun(args.request.run_id, `Failed to launch detached workflow: ${error instanceof Error ? error.message : String(error)}`).catch(() => undefined);
655
+ throw error;
656
+ }
657
+ finally {
658
+ await log.close();
659
+ }
660
+ }
661
+ async function verifyStoredModel(model, options = {}) {
662
+ const manifestPath = join(model.artifact_dir, "artifact-manifest.json");
663
+ const integrity = await assertArtifactManifest(manifestPath, {
664
+ requiredPaths: ["stage-metadata.json", "training-report.json"],
665
+ scopeToRequired: true,
666
+ verifyModel: true,
667
+ });
668
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
669
+ if (!manifest.model) {
670
+ throw new Error(`Artifact manifest does not contain a model contract: ${manifestPath}`);
671
+ }
672
+ const customNonServable = manifest.model.framework !== "transformers-peft"
673
+ && manifest.model.servable === false;
674
+ const artifact = await assertUsableModelArtifact(model.artifact_uri, {
675
+ allowUnrecognizedPayload: customNonServable && !options.requireServable,
676
+ });
677
+ if (typeof manifest.model.base_model_artifact_uri === "string"
678
+ && typeof manifest.model.base_model_fingerprint === "string") {
679
+ const actualBaseFingerprint = await fingerprintLocalBaseModel(manifest.model.base_model_artifact_uri);
680
+ if (actualBaseFingerprint !== manifest.model.base_model_fingerprint) {
681
+ throw new Error("Recorded local base-model content no longer matches the model artifact contract.");
682
+ }
683
+ }
684
+ if (typeof manifest.model.artifact_root !== "string"
685
+ || resolve(manifest.model.artifact_root) !== resolve(artifact.path)) {
686
+ throw new Error("Stored model record does not match the artifact covered by its manifest.");
687
+ }
688
+ if (manifest.model.base_model !== model.base_model) {
689
+ throw new Error("Stored model base model does not match its artifact manifest.");
690
+ }
691
+ if (options.requireServable && manifest.model.servable !== true) {
692
+ throw new Error(`Model ${model.id} is valid but its artifact contract does not mark it as locally servable.`);
693
+ }
694
+ return {
695
+ manifest_path: manifestPath,
696
+ integrity,
697
+ artifact,
698
+ contract: manifest.model,
699
+ };
700
+ }
701
+ async function verifyModelArtifactPath(input) {
702
+ const inputPath = resolve(input);
703
+ let manifestPath;
704
+ let artifactUri = inputPath;
705
+ if (basename(inputPath) === "artifact-manifest.json") {
706
+ manifestPath = inputPath;
707
+ const raw = JSON.parse(await readFile(inputPath, "utf8"));
708
+ if (typeof raw.model?.artifact_root !== "string") {
709
+ throw new Error(`Artifact manifest does not contain a model contract: ${inputPath}`);
710
+ }
711
+ artifactUri = raw.model.artifact_root;
712
+ }
713
+ else {
714
+ const metadata = await stat(inputPath).catch(() => null);
715
+ if (!metadata)
716
+ throw new Error(`Model not found and artifact path does not exist: ${input}`);
717
+ let current = metadata.isDirectory() ? inputPath : dirname(inputPath);
718
+ for (let depth = 0; depth < 8; depth += 1) {
719
+ const candidate = join(current, "artifact-manifest.json");
720
+ const raw = await readFile(candidate, "utf8").catch(() => null);
721
+ if (raw) {
722
+ const parsed = JSON.parse(raw);
723
+ if (typeof parsed.model?.artifact_root === "string"
724
+ && resolve(parsed.model.artifact_root) === resolve(inputPath)) {
725
+ manifestPath = candidate;
726
+ break;
727
+ }
728
+ }
729
+ const parent = dirname(current);
730
+ if (parent === current)
731
+ break;
732
+ current = parent;
733
+ }
734
+ }
735
+ if (!manifestPath) {
736
+ throw new Error(`No artifact manifest covering model path ${inputPath} was found in its parent run directory.`);
737
+ }
738
+ const integrity = await assertArtifactManifest(manifestPath, {
739
+ requiredPaths: ["stage-metadata.json", "training-report.json"],
740
+ scopeToRequired: true,
741
+ verifyModel: true,
742
+ });
743
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
744
+ if (!manifest.model)
745
+ throw new Error(`Artifact manifest does not contain a model contract: ${manifestPath}`);
746
+ const customNonServable = manifest.model.framework !== "transformers-peft"
747
+ && manifest.model.servable === false;
748
+ const artifact = await assertUsableModelArtifact(artifactUri, {
749
+ allowUnrecognizedPayload: customNonServable,
750
+ });
751
+ if (typeof manifest.model.base_model_artifact_uri === "string"
752
+ && typeof manifest.model.base_model_fingerprint === "string") {
753
+ const actualBaseFingerprint = await fingerprintLocalBaseModel(manifest.model.base_model_artifact_uri);
754
+ if (actualBaseFingerprint !== manifest.model.base_model_fingerprint) {
755
+ throw new Error("Recorded local base-model content no longer matches the model artifact contract.");
756
+ }
757
+ }
758
+ if (typeof manifest.model.artifact_root !== "string"
759
+ || resolve(manifest.model.artifact_root) !== resolve(artifact.path)) {
760
+ throw new Error("Model path does not match the artifact covered by its manifest.");
761
+ }
762
+ return { manifest_path: manifestPath, integrity, artifact, contract: manifest.model };
763
+ }
764
+ async function modelSystemPrompt(args) {
765
+ const specPath = readOption(args.argv, "--spec");
766
+ if (specPath && hasFlag(args.argv, "--no-spec-prompt")) {
767
+ throw new Error("Use only one of --spec or --no-spec-prompt.");
768
+ }
769
+ if (hasFlag(args.argv, "--no-spec-prompt"))
770
+ return undefined;
771
+ let spec;
772
+ if (specPath) {
773
+ const input = JSON.parse(await readFile(resolve(specPath), "utf8"));
774
+ const local = localBehaviorSpecFileSchema.safeParse(input);
775
+ const request = fineTuneRunRequestSchema.safeParse(input);
776
+ const snapshot = specSnapshotSchema.safeParse(input);
777
+ const parsedSpec = local.success ? local.data : request.success ? request.data.spec_snapshot : snapshot.success ? snapshot.data : null;
778
+ if (!parsedSpec)
779
+ throw new Error(`--spec must contain a TT Local behavior spec or run request: ${resolve(specPath)}`);
780
+ spec = parsedSpec;
781
+ }
782
+ else {
783
+ const runRequestPath = join(args.store.paths.runsDir, args.model.run_id, "request.json");
784
+ let persistedRequest = null;
785
+ try {
786
+ persistedRequest = fineTuneRunRequestSchema.parse(JSON.parse(await readFile(runRequestPath, "utf8")));
787
+ }
788
+ catch (error) {
789
+ if (error.code !== "ENOENT")
790
+ throw error;
791
+ }
792
+ spec = persistedRequest?.spec_snapshot
793
+ ?? (await args.store.getSpec(args.model.behavior_spec_id)).spec;
794
+ }
795
+ if (spec.base_model !== args.model.base_model) {
796
+ throw new Error(`Behavior spec base model ${spec.base_model} does not match stored model base ${args.model.base_model}.`);
797
+ }
798
+ const prompt = buildSystemMessage(spec);
799
+ const metadata = JSON.parse(await readFile(join(args.model.artifact_dir, "stage-metadata.json"), "utf8"));
800
+ const promptHash = createHash("sha256").update(prompt).digest("hex");
801
+ if (metadata.system_prompt_sha256 !== promptHash) {
802
+ throw new Error("Behavior spec instructions do not match the prompt fingerprint used for this trained model. "
803
+ + "Pass the original --spec, or use --no-spec-prompt only when this change is intentional.");
804
+ }
805
+ return prompt;
806
+ }
807
+ function modelServeDevice(argv) {
808
+ const value = readOption(argv, "--device");
809
+ if (value === undefined || value === "auto" || value === "cpu" || value === "cuda" || value === "mps") {
810
+ return value;
811
+ }
812
+ throw new Error(`--device must be one of auto, cpu, cuda, mps; got: ${value}`);
264
813
  }
265
814
  async function main(argv) {
266
- const command = argv[2] ?? "info";
815
+ const cli = parseCli(argv);
816
+ const command = cli.command;
817
+ if (command === "--version" || command === "-V") {
818
+ console.log(TT_LOCAL_VERSION);
819
+ return;
820
+ }
821
+ if (cli.help === "top" && (argv[2] === "--help" || argv[2] === "-h")) {
822
+ printHelp();
823
+ return;
824
+ }
825
+ if (cli.help === "group") {
826
+ printGroupHelp(command, COMMAND_GROUPS[command]);
827
+ return;
828
+ }
829
+ if (cli.help === "command" && cli.definition) {
830
+ printCommandHelp(cli.definition);
831
+ return;
832
+ }
267
833
  const loadedEnv = await loadDotEnvFromCwd();
268
834
  if (loadedEnv.length > 0 && !hasFlag(argv, "--quiet")) {
269
835
  process.stderr.write(`[tt-local] loaded ${loadedEnv.join(", ")} from .env\n`);
270
836
  }
271
- if (command === "info" || command === "--help" || command === "-h") {
837
+ if (command === "info") {
272
838
  const info = getLocalRunnerInfo();
273
839
  console.log(`${info.name}: ${info.description}`);
840
+ console.log(`Version: ${info.version}`);
274
841
  console.log(`Status: ${info.status}`);
275
- if (command !== "info")
276
- printHelp();
277
842
  return;
278
843
  }
279
844
  if (command === "doctor") {
280
845
  const config = await configFromArgv(argv);
281
- const checks = await runDoctor(config);
846
+ const request = cli.positionals[0]
847
+ ? (await loadLocalRunInput(resolve(cli.positionals[0]))).request
848
+ : undefined;
849
+ const checks = await runDoctor(config, request);
282
850
  const ok = checks.every((check) => check.ok);
283
851
  printJson({ ok, checks });
284
852
  if (!ok)
@@ -287,35 +855,52 @@ async function main(argv) {
287
855
  }
288
856
  if (command === "init") {
289
857
  const outputPath = resolve(readOption(argv, "--output") ?? DEFAULT_LOCAL_SPEC_PATH);
858
+ const profile = readOption(argv, "--profile");
859
+ if (profile !== undefined && profile !== "spark") {
860
+ throw new Error(`--profile must be spark, got: ${profile}`);
861
+ }
290
862
  const spec = await initLocalSpecFile({
291
863
  outputPath,
292
864
  name: readOption(argv, "--name") ?? "Local Tuned Tensor Spec",
293
865
  baseModel: readOption(argv, "--model") ?? "Qwen/Qwen3.5-2B",
294
866
  force: hasFlag(argv, "--force"),
295
867
  });
868
+ const configPath = profile
869
+ ? resolve(readOption(argv, "--config-output") ?? resolve(dirname(outputPath), "local-runner.json"))
870
+ : undefined;
871
+ if (profile && configPath) {
872
+ await initLocalRunnerConfigFile({
873
+ outputPath: configPath,
874
+ profile,
875
+ force: hasFlag(argv, "--force"),
876
+ });
877
+ }
296
878
  printJson({
297
879
  ok: true,
298
880
  path: outputPath,
299
881
  id: spec.id,
300
882
  name: spec.name,
301
883
  base_model: spec.base_model,
884
+ config_path: configPath ?? null,
302
885
  });
303
886
  return;
304
887
  }
305
888
  if (command === "validate") {
306
- const inputPath = resolve(readPositionals(argv)[0] ?? DEFAULT_LOCAL_SPEC_PATH);
889
+ const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
307
890
  const input = await loadLocalRunInput(inputPath, {
308
891
  userId: readOption(argv, "--user-id"),
309
892
  runNumber: readNumberOption(argv, "--run-number"),
310
893
  });
311
894
  const request = input.request;
312
895
  const config = await configFromArgv(argv);
896
+ assertLocalRunInputReady(request);
313
897
  assertSupportedValidateShape(request, config);
898
+ assertEvaluationScoringReady(config);
314
899
  printJson({
315
900
  ok: true,
316
901
  input_kind: input.kind,
317
902
  input_path: input.path,
318
- run_id: request.run_id,
903
+ run_id: input.kind === "request" ? request.run_id : null,
319
904
  behavior_spec_id: request.behavior_spec_id,
320
905
  training_method: request.training_method,
321
906
  base_model: request.spec_snapshot.base_model,
@@ -328,7 +913,7 @@ async function main(argv) {
328
913
  return;
329
914
  }
330
915
  if (command === "run") {
331
- const inputPath = resolve(readPositionals(argv)[0] ?? DEFAULT_LOCAL_SPEC_PATH);
916
+ const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
332
917
  const configInput = await configFromArgv(argv);
333
918
  const config = localRunnerConfigSchema.parse({
334
919
  ...configInput,
@@ -349,6 +934,28 @@ async function main(argv) {
349
934
  ? fineTuneRunRequestSchema.parse({ ...input.request, run_id: runId })
350
935
  : input.request;
351
936
  const request = withParentModelArtifact(baseRequest, await parentModelSelectionFromArgv(argv, config));
937
+ assertLocalRunInputReady(request);
938
+ assertSupportedValidateShape(request, config);
939
+ assertEvaluationScoringReady(config);
940
+ if (hasFlag(argv, "--detach")) {
941
+ const detached = await launchDetachedRun({ argv, request, config });
942
+ const configPath = readOption(argv, "--config");
943
+ const configSuffix = configPath ? ` --config ${JSON.stringify(resolve(configPath))}` : "";
944
+ printJson({
945
+ status: "queued",
946
+ detached: true,
947
+ run_id: request.run_id,
948
+ behavior_spec_id: request.behavior_spec_id,
949
+ pid: detached.pid,
950
+ log_path: detached.logPath,
951
+ artifact_dir: detached.artifactDir,
952
+ next: {
953
+ watch: `tt-local runs watch ${request.run_id}${configSuffix}`,
954
+ cancel: `tt-local runs cancel ${request.run_id}${configSuffix}`,
955
+ },
956
+ });
957
+ return;
958
+ }
352
959
  const stage = readRunStage(argv);
353
960
  const result = await runLocalFineTuneStage({
354
961
  request,
@@ -381,7 +988,7 @@ async function main(argv) {
381
988
  }
382
989
  else {
383
990
  printJson({
384
- status: "completed",
991
+ status: "stage_completed",
385
992
  stage: result.stage,
386
993
  input_kind: input.kind,
387
994
  run_id: result.request.run_id,
@@ -394,7 +1001,7 @@ async function main(argv) {
394
1001
  return;
395
1002
  }
396
1003
  if (command === "label") {
397
- const sourcePath = readPositionals(argv)[0];
1004
+ const sourcePath = cli.positionals[0];
398
1005
  if (!sourcePath)
399
1006
  throw new Error("label requires <input.jsonl|input.csv>");
400
1007
  const configInput = await configFromArgv(argv);
@@ -452,32 +1059,32 @@ async function main(argv) {
452
1059
  return;
453
1060
  }
454
1061
  if (command === "runs") {
455
- const subcommand = argv[3] ?? "list";
1062
+ const subcommand = cli.subcommand;
456
1063
  const config = await configFromArgv(argv);
457
1064
  const store = createLocalStore(config.storeRoot);
458
1065
  if (subcommand === "list")
459
1066
  return printJson(await store.listRuns());
460
1067
  if (subcommand === "get") {
461
- const id = argv[4];
1068
+ const id = cli.positionals[0];
462
1069
  if (!id)
463
1070
  throw new Error("runs get requires <run-id>");
464
1071
  return printJson(await store.getRun(id));
465
1072
  }
466
1073
  if (subcommand === "events") {
467
- const id = argv[4];
1074
+ const id = cli.positionals[0];
468
1075
  if (!id)
469
1076
  throw new Error("runs events requires <run-id>");
470
1077
  return printJson(await store.getRunEvents(id));
471
1078
  }
472
1079
  if (subcommand === "report") {
473
- const id = argv[4];
1080
+ const id = cli.positionals[0];
474
1081
  if (!id)
475
1082
  throw new Error("runs report requires <run-id>");
476
1083
  return printJson(await store.getRunReport(id));
477
1084
  }
478
1085
  if (subcommand === "compare") {
479
- const idA = argv[4];
480
- const idB = argv[5];
1086
+ const idA = cli.positionals[0];
1087
+ const idB = cli.positionals[1];
481
1088
  if (!idA || !idB)
482
1089
  throw new Error("runs compare requires <run-id-a> <run-id-b>");
483
1090
  const [reportA, reportB] = await Promise.all([
@@ -487,14 +1094,15 @@ async function main(argv) {
487
1094
  return printJson(compareRuns(reportA, reportB));
488
1095
  }
489
1096
  if (subcommand === "cancel") {
490
- const id = argv[4];
1097
+ const id = cli.positionals[0];
491
1098
  if (!id)
492
1099
  throw new Error("runs cancel requires <run-id>");
493
1100
  await store.cancelRun(id);
494
- return printJson({ ok: true, run_id: id });
1101
+ const run = await store.getRun(id);
1102
+ return printJson({ ok: true, run_id: run.id, status: run.status, current_stage: run.current_stage });
495
1103
  }
496
1104
  if (subcommand === "watch") {
497
- const id = argv[4];
1105
+ const id = cli.positionals[0];
498
1106
  if (!id)
499
1107
  throw new Error("runs watch requires <run-id>");
500
1108
  const printed = new Set();
@@ -507,7 +1115,7 @@ async function main(argv) {
507
1115
  console.log(`${event.occurred_at} ${event.stage} ${event.status}: ${event.message}`);
508
1116
  }
509
1117
  const run = await store.getRun(id);
510
- if (isTerminalStatus(run.status))
1118
+ if (isTerminalRunState(run))
511
1119
  return;
512
1120
  await sleep(1000);
513
1121
  }
@@ -519,19 +1127,30 @@ async function main(argv) {
519
1127
  throw new Error(`Unknown runs command: ${subcommand}`);
520
1128
  }
521
1129
  if (command === "models") {
522
- const subcommand = argv[3] ?? "list";
1130
+ const subcommand = cli.subcommand;
523
1131
  const config = await configFromArgv(argv);
524
1132
  const store = createLocalStore(config.storeRoot);
525
1133
  if (subcommand === "list")
526
1134
  return printJson(await store.listModels());
527
1135
  if (subcommand === "get") {
528
- const id = argv[4];
1136
+ const id = cli.positionals[0];
529
1137
  if (!id)
530
1138
  throw new Error("models get requires <model-id>");
531
1139
  return printJson(await store.getModel(id));
532
1140
  }
533
- if (subcommand === "prefetch") {
534
- const inputPath = resolve(readPositionals(argv, 4)[0] ?? DEFAULT_LOCAL_SPEC_PATH);
1141
+ if (subcommand === "verify") {
1142
+ const id = cli.positionals[0];
1143
+ if (!id)
1144
+ throw new Error("models verify requires <model-id-or-artifact-path>");
1145
+ if (await stat(resolve(id)).then(() => true, () => false)) {
1146
+ return printJson({ ok: true, model: null, ...await verifyModelArtifactPath(id) });
1147
+ }
1148
+ const model = await store.getModel(id);
1149
+ const verified = await verifyStoredModel(model);
1150
+ return printJson({ ok: true, model, ...verified });
1151
+ }
1152
+ if (subcommand === "prefetch" || subcommand === "verify-base") {
1153
+ const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
535
1154
  const input = await loadLocalRunInput(inputPath, {
536
1155
  userId: readOption(argv, "--user-id"),
537
1156
  runNumber: readNumberOption(argv, "--run-number"),
@@ -544,6 +1163,7 @@ async function main(argv) {
544
1163
  const report = await prefetchBaseModel({
545
1164
  request: input.request,
546
1165
  config,
1166
+ localOnly: subcommand === "verify-base",
547
1167
  reporter: createConsoleReporter({
548
1168
  verbose: hasFlag(argv, "--verbose"),
549
1169
  quiet: hasFlag(argv, "--quiet"),
@@ -555,22 +1175,68 @@ async function main(argv) {
555
1175
  input_path: input.path,
556
1176
  });
557
1177
  }
1178
+ if (subcommand === "serve") {
1179
+ const id = cli.positionals[0];
1180
+ if (!id)
1181
+ throw new Error("models serve requires <model-id>");
1182
+ const model = await store.getModel(id);
1183
+ const verified = await verifyStoredModel(model, { requireServable: true });
1184
+ const launch = buildLocalModelServerLaunch({
1185
+ model,
1186
+ config,
1187
+ options: {
1188
+ host: readOption(argv, "--host"),
1189
+ port: readNumberOption(argv, "--port"),
1190
+ device: modelServeDevice(argv),
1191
+ maxTokens: readNumberOption(argv, "--max-tokens"),
1192
+ temperature: readNumberOption(argv, "--temperature"),
1193
+ topP: readNumberOption(argv, "--top-p"),
1194
+ maxConcurrentRequests: readNumberOption(argv, "--max-concurrent-requests"),
1195
+ systemPrompt: await modelSystemPrompt({ argv, model, store }),
1196
+ allowRemote: hasFlag(argv, "--allow-remote"),
1197
+ apiKeyEnv: readOption(argv, "--api-key-env"),
1198
+ baseModelRevision: (() => {
1199
+ const contract = verified.contract;
1200
+ return typeof contract.base_model_revision === "string" ? contract.base_model_revision : undefined;
1201
+ })(),
1202
+ baseModelArtifactUri: (() => {
1203
+ const contract = verified.contract;
1204
+ return typeof contract.base_model_artifact_uri === "string" ? contract.base_model_artifact_uri : undefined;
1205
+ })(),
1206
+ },
1207
+ });
1208
+ if (hasFlag(argv, "--print-command")) {
1209
+ return printJson({
1210
+ ok: true,
1211
+ model_id: model.id,
1212
+ url: launch.url,
1213
+ command: launch.displayCommand,
1214
+ artifact_path: launch.artifactPath,
1215
+ manifest_path: verified.manifest_path,
1216
+ integrity: verified.integrity,
1217
+ });
1218
+ }
1219
+ process.stderr.write(`[tt-local] verified ${verified.integrity.checked} artifact file(s)\n`);
1220
+ process.stderr.write(`[tt-local] model API: ${launch.url}\n`);
1221
+ await serveLocalModel(launch);
1222
+ return;
1223
+ }
558
1224
  throw new Error(`Unknown models command: ${subcommand}`);
559
1225
  }
560
1226
  if (command === "specs") {
561
- const subcommand = argv[3] ?? "list";
1227
+ const subcommand = cli.subcommand;
562
1228
  const config = await configFromArgv(argv);
563
1229
  const store = createLocalStore(config.storeRoot);
564
1230
  if (subcommand === "list")
565
1231
  return printJson(await store.listSpecs());
566
1232
  if (subcommand === "get") {
567
- const id = argv[4];
1233
+ const id = cli.positionals[0];
568
1234
  if (!id)
569
1235
  throw new Error("specs get requires <spec-id>");
570
1236
  return printJson(await store.getSpec(id));
571
1237
  }
572
1238
  if (subcommand === "import") {
573
- const path = argv[4];
1239
+ const path = cli.positionals[0];
574
1240
  if (!path)
575
1241
  throw new Error("specs import requires <spec-or-request.json>");
576
1242
  const input = JSON.parse(await readFile(resolve(path), "utf8"));
@@ -593,7 +1259,7 @@ async function main(argv) {
593
1259
  throw new Error(`Unknown specs command: ${subcommand}`);
594
1260
  }
595
1261
  if (command === "store") {
596
- const subcommand = argv[3];
1262
+ const subcommand = cli.subcommand;
597
1263
  const config = await configFromArgv(argv);
598
1264
  const store = createLocalStore(config.storeRoot);
599
1265
  if (subcommand === "rebuild-index") {