summaryrefslogtreecommitdiff
path: root/src/subcommand.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-15 16:14:55 -0600
committert <t@tjp.lol>2026-06-15 16:15:10 -0600
commitd9d4131cb321a9ce29b000cd7aed8ced9a18efc1 (patch)
tree5cc65cf6b0d226daa716f00aa6f667e8cf6dfa61 /src/subcommand.zig
parent02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (diff)
model registry sync via models.dev
Diffstat (limited to 'src/subcommand.zig')
-rw-r--r--src/subcommand.zig275
1 files changed, 275 insertions, 0 deletions
diff --git a/src/subcommand.zig b/src/subcommand.zig
index bb46941..5238669 100644
--- a/src/subcommand.zig
+++ b/src/subcommand.zig
@@ -25,6 +25,7 @@ const luarocks_runtime = @import("luarocks_runtime.zig");
const self_exe = @import("self_exe.zig");
const session_paths = @import("session_paths.zig");
const config_file = @import("config_file.zig");
+const models_toml = @import("models_toml.zig");
const panto_home = @import("panto_home.zig");
const panto = @import("panto");
@@ -80,6 +81,10 @@ pub fn dispatch(
try runSessionsSubcommand(allocator, io, environ_map);
return .done;
}
+ if (std.mem.eql(u8, sub, "models")) {
+ try runModelsSubcommand(allocator, io, environ_map, &it);
+ return .done;
+ }
if (std.mem.eql(u8, sub, "auth")) {
try runAuthSubcommand(allocator, io, environ_map, &it);
return .done;
@@ -103,6 +108,7 @@ fn printHelp(io: Io) !void {
\\ panto --resume Resume the most recent conversation in this directory.
\\ panto --resume <id> Resume the conversation whose id begins with <id>.
\\ panto sessions List saved sessions for this directory.
+ \\ panto models sync Fetch models.dev and rebuild the base models.toml.
\\ panto auth status Show configured auth sessions and login state.
\\ panto auth login <name>
\\ Log in to an OAuth auth session (device flow).
@@ -324,6 +330,275 @@ fn trimCreated(iso: []const u8) []const u8 {
}
// ---------------------------------------------------------------------------
+// `panto models`
+// ---------------------------------------------------------------------------
+
+const ModelsSyncResult = struct {
+ content: []u8,
+ providers_synced: usize,
+ providers_skipped: usize,
+ models_written: usize,
+};
+
+/// `panto models sync` — fetch models.dev and rebuild the base-layer
+/// `models.toml` from the configured providers only.
+fn runModelsSubcommand(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ it: *std.process.Args.Iterator,
+) !void {
+ var out_buf: [4096]u8 = undefined;
+ var out_file = std.Io.File.stdout().writer(io, &out_buf);
+ const out = &out_file.interface;
+
+ const action = it.next() orelse {
+ try out.writeAll("usage: panto models sync\n");
+ try out_file.flush();
+ return;
+ };
+ if (!std.mem.eql(u8, action, "sync")) {
+ try out.print("panto models: unknown action '{s}'\nusage: panto models sync\n", .{action});
+ try out_file.flush();
+ return;
+ }
+ if (it.next()) |extra| {
+ try out.print("panto models sync: unexpected argument '{s}'\n", .{extra});
+ try out_file.flush();
+ return;
+ }
+
+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const cwd_n = try std.process.currentPath(io, &cwd_buf);
+ const cwd = cwd_buf[0..cwd_n];
+
+ var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| {
+ std.log.err("panto models sync: failed to load config ({t})", .{err});
+ return err;
+ };
+ defer cfg.deinit();
+
+ const catalog_json = try fetchModelsDevCatalog(allocator, io);
+ defer allocator.free(catalog_json);
+
+ const sync = try buildSyncedModelsToml(allocator, &cfg, catalog_json);
+ defer allocator.free(sync.content);
+
+ const base_models_path = try models_toml.basePath(allocator, environ_map);
+ defer allocator.free(base_models_path);
+ if (std.fs.path.dirname(base_models_path)) |parent| {
+ Io.Dir.cwd().createDirPath(io, parent) catch |err| switch (err) {
+ error.PathAlreadyExists => {},
+ else => return err,
+ };
+ }
+ try Io.Dir.cwd().writeFile(io, .{ .sub_path = base_models_path, .data = sync.content });
+
+ try out.print(
+ "synced {d} model(s) across {d} provider(s) to {s}\n",
+ .{ sync.models_written, sync.providers_synced, base_models_path },
+ );
+ if (sync.providers_skipped > 0) {
+ try out.print(
+ "{d} configured provider(s) had no matching catalog entry and were skipped\n",
+ .{sync.providers_skipped},
+ );
+ }
+ try out_file.flush();
+}
+
+fn fetchModelsDevCatalog(allocator: Allocator, io: Io) ![]u8 {
+ panto.init(allocator, io);
+ defer panto.deinit();
+ const client = panto.httpClient();
+
+ var body: std.Io.Writer.Allocating = .init(allocator);
+ defer body.deinit();
+
+ const headers = [_]std.http.Header{.{ .name = "User-Agent", .value = "panto models sync" }};
+ const res = try client.fetch(.{
+ .location = .{ .url = "https://models.dev/api.json" },
+ .extra_headers = &headers,
+ .response_writer = &body.writer,
+ });
+ if (res.status != .ok) return error.ModelCatalogFetchFailed;
+ return try body.toOwnedSlice();
+}
+
+fn buildSyncedModelsToml(
+ allocator: Allocator,
+ cfg: *const config_file.Config,
+ catalog_json: []const u8,
+) !ModelsSyncResult {
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, catalog_json, .{});
+ defer parsed.deinit();
+ const root = switch (parsed.value) {
+ .object => |obj| obj,
+ else => return error.InvalidModelCatalog,
+ };
+
+ var out: std.Io.Writer.Allocating = .init(allocator);
+ errdefer out.deinit();
+
+ try out.writer.writeAll(
+ "# panto base models config (generated by `panto models sync`).\n" ++
+ "#\n" ++
+ "# This is the lowest-precedence models layer. Override anything here in:\n" ++
+ "# ~/.config/panto/models.toml\n" ++
+ "# ./.panto/models.toml\n" ++
+ "# ./.panto/models.local.toml\n" ++
+ "#\n" ++
+ "# Generated from https://models.dev/api.json for the providers currently\n" ++
+ "# configured in panto. Safe to overwrite by running the sync again.\n\n",
+ );
+
+ var providers_synced: usize = 0;
+ var providers_skipped: usize = 0;
+ var models_written: usize = 0;
+
+ for (cfg.providers) |p| {
+ const catalog_name = p.model_catalog_name orelse p.name;
+ const provider_v = root.get(catalog_name) orelse {
+ providers_skipped += 1;
+ continue;
+ };
+ const provider_obj = switch (provider_v) {
+ .object => |obj| obj,
+ else => {
+ providers_skipped += 1;
+ continue;
+ },
+ };
+ const models_v = provider_obj.get("models") orelse {
+ providers_skipped += 1;
+ continue;
+ };
+ const models_obj = switch (models_v) {
+ .object => |obj| obj,
+ else => {
+ providers_skipped += 1;
+ continue;
+ },
+ };
+
+ var wrote_provider = false;
+ var it_models = models_obj.iterator();
+ while (it_models.next()) |kv| {
+ const model_obj = switch (kv.value_ptr.*) {
+ .object => |obj| obj,
+ else => continue,
+ };
+ const model_id = if (model_obj.get("id")) |id_v|
+ switch (id_v) {
+ .string => |s| s,
+ else => kv.key_ptr.*,
+ }
+ else
+ kv.key_ptr.*;
+
+ if (!wrote_provider) {
+ wrote_provider = true;
+ providers_synced += 1;
+ try out.writer.print("# provider {s} (catalog: {s})\n", .{ p.name, catalog_name });
+ }
+
+ try writeTomlTableHeader(&out.writer, p.name, model_id);
+ if (model_obj.get("limit")) |limit_v| switch (limit_v) {
+ .object => |limit_obj| {
+ if (limit_obj.get("context")) |context_v| {
+ if (jsonPositiveU32(context_v)) |n| {
+ try out.writer.print("context_window = {d}\n", .{n});
+ }
+ }
+ if (limit_obj.get("output")) |output_v| {
+ if (jsonPositiveU32(output_v)) |n| {
+ try out.writer.print("max_tokens = {d}\n", .{n});
+ }
+ }
+ },
+ else => {},
+ };
+ if (model_obj.get("cost")) |cost_v| switch (cost_v) {
+ .object => |cost_obj| {
+ try writeTomlPriceField(&out.writer, "input", cost_obj.get("input"));
+ try writeTomlPriceField(&out.writer, "output", cost_obj.get("output"));
+ try writeTomlPriceField(&out.writer, "cache_read", cost_obj.get("cache_read"));
+ try writeTomlPriceField(&out.writer, "cache_write", cost_obj.get("cache_write"));
+ },
+ else => {},
+ };
+ try out.writer.writeByte('\n');
+ models_written += 1;
+ }
+
+ if (wrote_provider) {
+ try out.writer.writeByte('\n');
+ } else {
+ providers_skipped += 1;
+ }
+ }
+
+ return .{
+ .content = try out.toOwnedSlice(),
+ .providers_synced = providers_synced,
+ .providers_skipped = providers_skipped,
+ .models_written = models_written,
+ };
+}
+
+fn writeTomlTableHeader(w: *std.Io.Writer, provider: []const u8, alias: []const u8) !void {
+ try w.writeByte('[');
+ try writeTomlBasicString(w, provider);
+ try w.writeByte('.');
+ try writeTomlBasicString(w, alias);
+ try w.writeAll("]\n");
+}
+
+fn writeTomlBasicString(w: *std.Io.Writer, s: []const u8) !void {
+ try w.writeByte('"');
+ for (s) |ch| switch (ch) {
+ '\\' => try w.writeAll("\\\\"),
+ '"' => try w.writeAll("\\\""),
+ '\n' => try w.writeAll("\\n"),
+ '\r' => try w.writeAll("\\r"),
+ '\t' => try w.writeAll("\\t"),
+ else => try w.writeByte(ch),
+ };
+ try w.writeByte('"');
+}
+
+fn jsonPositiveU32(v: std.json.Value) ?u32 {
+ return switch (v) {
+ .integer => |i| if (i > 0 and i <= std.math.maxInt(u32)) @intCast(i) else null,
+ .float => |f| if (std.math.isFinite(f) and f > 0 and f <= @as(f64, @floatFromInt(std.math.maxInt(u32)))) @intFromFloat(f) else null,
+ .number_string => |s| std.fmt.parseInt(u32, s, 10) catch null,
+ else => null,
+ };
+}
+
+fn jsonNonNegativeNumber(v: std.json.Value) ?f64 {
+ return switch (v) {
+ .integer => |i| if (i >= 0) @floatFromInt(i) else null,
+ .float => |f| if (std.math.isFinite(f) and f >= 0) f else null,
+ .number_string => |s| blk: {
+ const f = std.fmt.parseFloat(f64, s) catch break :blk null;
+ break :blk if (std.math.isFinite(f) and f >= 0) f else null;
+ },
+ else => null,
+ };
+}
+
+fn writeTomlPriceField(w: *std.Io.Writer, name: []const u8, v: ?std.json.Value) !void {
+ const raw = v orelse return;
+ const n = jsonNonNegativeNumber(raw) orelse return;
+ if (@round(n) == n) {
+ try w.print("{s} = {d}\n", .{ name, @as(i64, @intFromFloat(n)) });
+ } else {
+ try w.print("{s} = {d}\n", .{ name, n });
+ }
+}
+
+// ---------------------------------------------------------------------------
// `panto auth`
// ---------------------------------------------------------------------------