From d9d4131cb321a9ce29b000cd7aed8ced9a18efc1 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 15 Jun 2026 16:14:55 -0600 Subject: model registry sync via models.dev --- src/subcommand.zig | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) (limited to 'src/subcommand.zig') 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 Resume the conversation whose id begins with . \\ 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 \\ Log in to an OAuth auth session (device flow). @@ -323,6 +329,275 @@ fn trimCreated(iso: []const u8) []const u8 { return iso[0..16]; } +// --------------------------------------------------------------------------- +// `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` // --------------------------------------------------------------------------- -- cgit v1.3