diff options
Diffstat (limited to 'src/subcommand.zig')
| -rw-r--r-- | src/subcommand.zig | 275 |
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` // --------------------------------------------------------------------------- |
