diff --git a/CHANGELOG.md b/CHANGELOG.md index c923f019c8..4a40563961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - Docs: the pull request pre-flight asks a PR that adds, finishes or drops a family's optimization to update that family's cell in the optimization ledger (#2243). Thanks to @jschmied. - Server: a refused `--api-key-file` logs the path it was given, the mode the file system reported and which test failed, once at startup and once when a reload is refused (#2365). Thanks to @Whq02. +- CLI: `tensorfold models --json` prints the cached checkpoints as one document (`schema` 1, each checkpoint's `repo`, `family` and `model_type`); the lines are unchanged (#2364). Thanks to @Whq02. - Server: tool calls completed just before the thinking-close marker are returned when no answer follows, whole and streamed; calls followed by more reasoning or an answer stay in reasoning (#2313, fixes #2310). Thanks to @AdrianBinDC. diff --git a/README.md b/README.md index 270d356107..01b5b03311 100644 --- a/README.md +++ b/README.md @@ -234,12 +234,14 @@ On Metal, each of up to 128 streams in the 27B's shared rounds drafts its own DF ```sh tensorfold pull TensorFold/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bit tensorfold models +tensorfold models --json tensorfold info TensorFold/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bit ``` `pull` downloads a checkpoint into the Hugging Face cache and checks what it downloads against the Hub: large files by sha256, small files by their git blob sha1. Large files come down in pieces over up to 16 connections, and an interrupted pull resumes with the pieces it lacks. It refuses a checkpoint whose `model_type` has no native family, and fetches the 27B's DFlash2 drafter for `--drafter`. `models` lists the cached checkpoints with a model type it knows, and `info` shows one checkpoint's family, format, context and memory floor. +`models --json` prints the same list as one document for programs: `schema` 1, and each checkpoint's `repo`, `family` and `model_type`; `family` is a display title and `model_type` is the key to match on. `tensorfold serve REPO` serves a cached checkpoint by its repository name. ## Context compaction diff --git a/zig/src/cli/cli.zig b/zig/src/cli/cli.zig index 41c443f508..e8aeffa579 100644 --- a/zig/src/cli/cli.zig +++ b/zig/src/cli/cli.zig @@ -8,7 +8,7 @@ const pull_exlr8 = @import("pull_exlr8.zig"); const lora_merge = @import("core").lora_merge; const usage = - \\usage: tensorfold models + \\usage: tensorfold models [--json] \\ tensorfold info MODEL \\ tensorfold pull REPO[@REVISION] \\ tensorfold pull REPO[@REVISION] --exlr8 PROFILE --node R/N --dir DIR @@ -38,11 +38,12 @@ pub fn main(init: std.process.Init, argv: []const [:0]const u8) !u8 { for (args, argv) |*x, y| x.* = y; const env = init.environ_map; if (std.mem.eql(u8, args[0], "models")) { - if (args.len != 1) { + const json = args.len == 2 and std.mem.eql(u8, args[1], "--json"); + if (args.len != 1 and !json) { try err.interface.writeAll(usage); return 2; } - return models.run(a, init.io, &out.interface, env, null); + return models.run(a, init.io, &out.interface, env, null, json); } if (std.mem.eql(u8, args[0], "pull")) { if (args.len < 2) { diff --git a/zig/src/cli/models.zig b/zig/src/cli/models.zig index 8d0f1040f7..4a7329d3d4 100644 --- a/zig/src/cli/models.zig +++ b/zig/src/cli/models.zig @@ -3,15 +3,19 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const hub = @import("hub.zig"); -/// Lists each cache checkpoint whose model_type a Zig family serves. Returns the process exit code. -pub fn run(a: Allocator, io: std.Io, out: *std.Io.Writer, env: ?*const std.process.Environ.Map, override: ?[]const u8) !u8 { +/// One listed checkpoint: the facts a line prints, and the fields of a `--json` entry. +const Listed = struct { repo: []const u8, family: []const u8, model_type: []const u8 }; + +/// The cache checkpoints a Zig family serves, as lines or as one JSON document. Returns the process exit code. +pub fn run(a: Allocator, io: std.Io, out: *std.Io.Writer, env: ?*const std.process.Environ.Map, override: ?[]const u8, json: bool) !u8 { const root = try hub.cacheDir(a, env, override); var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch { + if (json) return printJson(out, &.{}); try out.print("no checkpoints on disk: the Hugging Face cache at {s} does not exist yet; run: tensorfold pull OWNER/NAME\n", .{root}); return 0; }; defer dir.close(io); - var found: usize = 0; + var listed: std.ArrayList(Listed) = .empty; var it = dir.iterate(); while (try it.next(io)) |entry| { if (entry.kind != .directory) continue; @@ -19,10 +23,18 @@ pub fn run(a: Allocator, io: std.Io, out: *std.Io.Writer, env: ?*const std.proce const snapshot = (try hub.cachedSnapshot(a, io, root, repo)) orelse continue; const model_type = hub.modelType(a, io, snapshot); const family = hub.family(model_type) orelse continue; - try out.print("{s}: {s} ({s})\n", .{ repo, family.title, family.model_type }); - found += 1; + try listed.append(a, .{ .repo = repo, .family = family.title, .model_type = family.model_type }); } - if (found == 0) try out.print("no checkpoints on disk that a registered Zig family can serve; run: tensorfold pull OWNER/NAME\n", .{}); + if (json) return printJson(out, listed.items); + for (listed.items) |m| try out.print("{s}: {s} ({s})\n", .{ m.repo, m.family, m.model_type }); + if (listed.items.len == 0) try out.print("no checkpoints on disk that a registered Zig family can serve; run: tensorfold pull OWNER/NAME\n", .{}); + return 0; +} + +/// `{"schema": 1, "models": [...]}`: the same checkpoints the lines print, one object each, for programs. +fn printJson(out: *std.Io.Writer, listed: []const Listed) !u8 { + try std.json.Stringify.value(.{ .schema = 1, .models = listed }, .{}, out); + try out.writeByte('\n'); return 0; } @@ -36,13 +48,9 @@ fn repoFromDirName(a: Allocator, name: []const u8) !?[]const u8 { return try std.fmt.allocPrint(a, "{s}/{s}", .{ rest[0..sep], rest[sep + 2 ..] }); } -test "models lists serve-able checkpoints and skips families Zig cannot serve" { - var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena_state.deinit(); - const a = arena_state.allocator(); - const io = std.testing.io; +/// A cache with one checkpoint a Zig family serves (Org/Flash) and one none does (Org/Draft); the caller deletes it. +fn syntheticCache(a: Allocator, io: std.Io) ![]const u8 { const root = try std.fmt.allocPrint(a, ".tf-models-test-{d}", .{std.Io.Clock.awake.now(io).toNanoseconds()}); - defer std.Io.Dir.cwd().deleteTree(io, root) catch {}; const w = std.Io.Dir.cwd(); try w.createDirPath(io, try std.fs.path.join(a, &.{ root, "models--Org--Flash/snapshots/rev1" })); try w.createDirPath(io, try std.fs.path.join(a, &.{ root, "models--Org--Flash/refs" })); @@ -50,15 +58,50 @@ test "models lists serve-able checkpoints and skips families Zig cannot serve" { try w.writeFile(io, .{ .sub_path = try std.fs.path.join(a, &.{ root, "models--Org--Flash/snapshots/rev1/config.json" }), .data = "{\"model_type\": \"qwen4_exp\", \"quantization\": {\"bits\": 6, \"group_size\": 32}}" }); try w.createDirPath(io, try std.fs.path.join(a, &.{ root, "models--Org--Draft/snapshots/rev2" })); try w.writeFile(io, .{ .sub_path = try std.fs.path.join(a, &.{ root, "models--Org--Draft/snapshots/rev2/config.json" }), .data = "{\"model_type\": \"gemma3n\"}" }); + return root; +} + +test "models lists serve-able checkpoints and skips families Zig cannot serve" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const io = std.testing.io; + const root = try syntheticCache(a, io); + defer std.Io.Dir.cwd().deleteTree(io, root) catch {}; var out: std.Io.Writer.Allocating = .init(a); - const code = try run(a, io, &out.writer, null, root); + const code = try run(a, io, &out.writer, null, root, false); try std.testing.expectEqual(@as(u8, 0), code); const listed = out.written(); try std.testing.expect(std.mem.indexOf(u8, listed, "Org/Flash: Qwen3.8 Flash Next (qwen4_exp)") != null); try std.testing.expect(std.mem.indexOf(u8, listed, "Org/Draft") == null); } +test "models --json prints the listed checkpoints as one document, and an absent cache as none" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const io = std.testing.io; + const root = try syntheticCache(a, io); + defer std.Io.Dir.cwd().deleteTree(io, root) catch {}; + + var out: std.Io.Writer.Allocating = .init(a); + try std.testing.expectEqual(@as(u8, 0), try run(a, io, &out.writer, null, root, true)); + var parsed = try std.json.parseFromSlice(std.json.Value, a, out.written(), .{}); + defer parsed.deinit(); + try std.testing.expectEqual(@as(i64, 1), parsed.value.object.get("schema").?.integer); + const models = parsed.value.object.get("models").?.array.items; + try std.testing.expectEqual(@as(usize, 1), models.len); + try std.testing.expectEqualStrings("Org/Flash", models[0].object.get("repo").?.string); + try std.testing.expectEqualStrings("Qwen3.8 Flash Next", models[0].object.get("family").?.string); + try std.testing.expectEqualStrings("qwen4_exp", models[0].object.get("model_type").?.string); + + var none: std.Io.Writer.Allocating = .init(a); + const missing = try std.fs.path.join(a, &.{ root, "missing" }); + try std.testing.expectEqual(@as(u8, 0), try run(a, io, &none.writer, null, missing, true)); + try std.testing.expectEqualStrings("{\"schema\":1,\"models\":[]}\n", none.written()); +} + fn has(text: []const u8, needle: []const u8) bool { if (std.mem.indexOf(u8, text, needle) != null) return true; std.debug.print("missing \"{s}\" in:\n{s}\n", .{ needle, text }); diff --git a/zig/src/server/main.zig b/zig/src/server/main.zig index ee42dc2b2a..5332e0ea07 100644 --- a/zig/src/server/main.zig +++ b/zig/src/server/main.zig @@ -57,7 +57,7 @@ pub fn main(init: std.process.Init) !u8 { return 0; } if (argv.len == 2 and (std.mem.eql(u8, argv[1], "--help") or std.mem.eql(u8, argv[1], "-h"))) { - try std.Io.File.stdout().writeStreamingAll(io, "usage: tensorfold-native --version | capabilities --json | models | info MODEL | pull REPO[@REVISION] | slide rollback FOLDER | slide audit [flags] | serve MODEL [flags] | benchmark MODEL [flags]\n" ++ usage_line); + try std.Io.File.stdout().writeStreamingAll(io, "usage: tensorfold-native --version | capabilities --json | models [--json] | info MODEL | pull REPO[@REVISION] | slide rollback FOLDER | slide audit [flags] | serve MODEL [flags] | benchmark MODEL [flags]\n" ++ usage_line); return 0; } if (argv.len >= 3 and std.mem.eql(u8, argv[1], "serve")) { @@ -87,7 +87,7 @@ pub fn main(init: std.process.Init) !u8 { } else return benchmark.main(init, argv[2..], engines.capabilities(a)); } if (argv.len < 2 or !std.mem.eql(u8, argv[1], "serve")) { - std.debug.print("usage: tensorfold-native capabilities --json | models | info MODEL | pull REPO[@REVISION] | slide rollback FOLDER | slide audit [flags] | serve MODEL [flags] | benchmark MODEL [flags]\n", .{}); + std.debug.print("usage: tensorfold-native capabilities --json | models [--json] | info MODEL | pull REPO[@REVISION] | slide rollback FOLDER | slide audit [flags] | serve MODEL [flags] | benchmark MODEL [flags]\n", .{}); return 2; } const started = std.Io.Clock.awake.now(io).toNanoseconds();