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/config_file.zig | 35 +++++- src/luarocks_runtime.zig | 2 + src/main.zig | 15 ++- src/models_toml.zig | 269 ++++++++++++++++++++++++++++++++++++++++----- src/subcommand.zig | 275 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 559 insertions(+), 37 deletions(-) (limited to 'src') diff --git a/src/config_file.zig b/src/config_file.zig index 8f6725c..a70a832 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -35,8 +35,10 @@ //! currently resolved. An `anthropic_messages` provider may also set //! `prompt_cache = ` (default true) to control the advancing //! `cache_control` breakpoint; it is ignored for `openai_chat` -//! providers. `extra_headers` (a table of string headers) rides on the -//! model request. +//! providers. `model_catalog_name = ` optionally maps the +//! provider to an external model catalog key (sync tooling otherwise +//! falls back to the provider name). `extra_headers` (a table of string +//! headers) rides on the model request. //! - `defaults.model` is parsed as `:`. //! - `tools` / `extensions` allow- and deny-lists are captured verbatim //! (as glob pattern lists). A pattern appearing in both allow and deny @@ -84,6 +86,9 @@ pub const Provider = struct { base_url: []const u8, /// The `auth.` session this provider draws its credential from. auth_name: []const u8, + /// Optional external model-catalog provider key (e.g. `github-copilot` + /// on models.dev). Absent => sync tooling falls back to `name`. + model_catalog_name: ?[]const u8 = null, /// anthropic_messages only. Place one advancing `cache_control` /// breakpoint on each request. Defaults to the library default (true); /// ignored for `openai_chat` providers. @@ -97,6 +102,7 @@ pub const Provider = struct { if (self.dialect) |d| alloc.free(d); alloc.free(self.base_url); alloc.free(self.auth_name); + if (self.model_catalog_name) |m| alloc.free(m); // `extra_headers` lives in the Config auth arena; not freed here. } }; @@ -708,6 +714,8 @@ fn resolveProvider( const auth_name = (val.get("auth") orelse return error.MissingProviderAuth).asString() orelse return error.MissingProviderAuth; + const model_catalog_name = tomlStr(val, "model_catalog_name"); + // anthropic_messages only; absent => library default (true). const prompt_cache: bool = blk: { if (val.get("prompt_cache")) |pc| { @@ -724,6 +732,7 @@ fn resolveProvider( .dialect = if (dialect) |d| try allocator.dupe(u8, d) else null, .base_url = try allocator.dupe(u8, base_url), .auth_name = try allocator.dupe(u8, auth_name), + .model_catalog_name = if (model_catalog_name) |m| try allocator.dupe(u8, m) else null, .prompt_cache = prompt_cache, .extra_headers = extra_headers, }; @@ -1254,6 +1263,28 @@ test "resolve: provider extra_headers flow into the built provider config" { try testing.expectEqualStrings("tok", pc.openai_chat.api_key); } +test "resolve: provider model_catalog_name is captured" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + const src = + \\[providers.copilot] + \\style = "openai_chat" + \\base_url = "https://api.individual.githubcopilot.com" + \\auth = "k" + \\model_catalog_name = "github-copilot" + \\ + \\[auth.k] + \\key = "tok" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + + try testing.expectEqualStrings("github-copilot", cfg.provider("copilot").?.model_catalog_name.?); +} + test "resolve: openai_responses codex dialect maps to internal API style" { const a = testing.allocator; var env = emptyEnv(a); diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig index 7f2e161..7991e4e 100644 --- a/src/luarocks_runtime.zig +++ b/src/luarocks_runtime.zig @@ -415,6 +415,7 @@ const default_base_config = \\# style = "openai_chat" \\# base_url = "https://api.individual.githubcopilot.com" # fallback; the exchange overrides it \\# auth = "github_copilot" + \\# model_catalog_name = "github-copilot" # for `panto models sync` \\# \\# [providers.copilot.extra_headers] \\# User-Agent = "GitHubCopilotChat/0.35.0" @@ -440,6 +441,7 @@ const default_base_config = \\# dialect = "codex" \\# base_url = "https://chatgpt.com/backend-api" \\# auth = "openai_codex" + \\# model_catalog_name = "openai" # for `panto models sync` \\# \\# [providers.codex.extra_headers] \\# OpenAI-Beta = "responses=experimental" diff --git a/src/main.zig b/src/main.zig index 8ac5e8b..dbaa4d8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -209,16 +209,15 @@ pub fn main(init: std.process.Init) !void { }; defer app_config.deinit(); - // Load models.toml — model aliases (wire name + knobs) and pricing. - // A missing file is fine (empty registries); cost lookups return - // null, formatted as "unknown" by the display layer. - const models_toml_path = try models_toml.configPath(alloc, init.environ_map); - defer alloc.free(models_toml_path); - var models = try models_toml.loadFromPath(alloc, io, models_toml_path); + // Load models.toml — model aliases (wire name + knobs) and pricing — + // merged across the base/user/project/local layers. Missing files are + // fine (empty registries); cost lookups return null, formatted as + // "unknown" by the display layer. + var models = try models_toml.load(alloc, io, init.environ_map, cwd); defer models.deinit(); std.log.debug( - "models.toml: {d} model def(s), {d} price entr(ies) from {s}", - .{ models.defs.count(), models.pricing.count(), models_toml_path }, + "models.toml: {d} model def(s), {d} price entr(ies) from merged layers", + .{ models.defs.count(), models.pricing.count() }, ); // `models.pricing` is parsed and ready; the print-based CLI doesn't // surface per-turn cost yet (that lands with the TUI frontend). diff --git a/src/models_toml.zig b/src/models_toml.zig index 622bdf6..6527292 100644 --- a/src/models_toml.zig +++ b/src/models_toml.zig @@ -1,4 +1,14 @@ -//! Loader for `models.toml` — model aliases and per-model pricing. +//! Layered loader for `models.toml` — model aliases and per-model pricing. +//! +//! Four files are read and merged, lowest precedence first: +//! +//! 1. base — `$PANTO_HOME/models.toml` +//! (or `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`) +//! 2. user — `${XDG_CONFIG_HOME:-$HOME/.config}/panto/models.toml` +//! 3. project — `./.panto/models.toml` +//! 4. local — `./.panto/models.local.toml` +//! +//! Tables merge recursively; later scalar values overwrite earlier ones. //! //! A model entry is keyed by `.`, where //! `` matches a provider defined in `config.toml`'s @@ -8,8 +18,9 @@ //! Schema: //! //! [.] -//! model = # wire model id; defaults to if omitted -//! max_tokens = # per-request output token cap +//! model = # wire model id; defaults to if omitted +//! context_window = # total input context, for metadata/UI +//! max_tokens = # per-request output token cap //! # pricing (all optional; USD per million tokens): //! input = //! output = @@ -67,6 +78,7 @@ const Io = std.Io; const toml = @import("toml"); const panto = @import("panto"); +const tvalue = toml.value_mod; pub const Pricing = panto.Pricing; pub const Registry = panto.PricingRegistry; @@ -86,6 +98,8 @@ pub const ModelDef = struct { /// TOML omitted `model`. model: []const u8, reasoning: ReasoningEffort, + /// Approximate total input context window, for picker/UI metadata. + context_window: ?u32 = null, /// Per-request output token cap; null = use the provider/library default. max_tokens: ?u32, /// anthropic_messages only; null = use the library default. @@ -147,9 +161,25 @@ pub const Models = struct { } }; -/// Resolve the absolute path to `models.toml`. Honors `XDG_CONFIG_HOME`, -/// falling back to `$HOME/.config`. Caller owns the returned slice. -pub fn configPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 { +/// Resolve the base-layer path to `models.toml`: `$PANTO_HOME/models.toml`, +/// falling back to `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`. +/// Caller owns the returned slice. +pub fn basePath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 { + if (environ_map.get("PANTO_HOME")) |home| { + return try std.fs.path.join(allocator, &.{ home, "models.toml" }); + } + if (environ_map.get("XDG_DATA_HOME")) |xdg| { + return try std.fs.path.join(allocator, &.{ xdg, "panto", "models.toml" }); + } + if (environ_map.get("HOME")) |home| { + return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "models.toml" }); + } + return error.NoHomeDirectory; +} + +/// Resolve the user-layer path to `models.toml`. Caller owns the returned +/// slice. +pub fn userPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 { if (environ_map.get("XDG_CONFIG_HOME")) |xdg| { return try std.fs.path.join(allocator, &.{ xdg, "panto", "models.toml" }); } @@ -159,35 +189,103 @@ pub fn configPath(allocator: Allocator, environ_map: *const std.process.Environ. return error.NoHomeDirectory; } -/// Load `models.toml` from `path`. A missing file yields empty registries -/// (no error). Parse errors return `error.InvalidModelsToml`. -pub fn loadFromPath(allocator: Allocator, io: Io, path: []const u8) !Models { +/// Back-compat alias for the user-layer path. +pub fn configPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 { + return userPath(allocator, environ_map); +} + +const LayerPaths = struct { + base: []u8, + user: []u8, + project: []u8, + local: []u8, + + fn deinit(self: LayerPaths, allocator: Allocator) void { + allocator.free(self.base); + allocator.free(self.user); + allocator.free(self.project); + allocator.free(self.local); + } +}; + +fn layerPaths( + allocator: Allocator, + environ_map: *const std.process.Environ.Map, + cwd: []const u8, +) !LayerPaths { + const base = try basePath(allocator, environ_map); + errdefer allocator.free(base); + const user = try userPath(allocator, environ_map); + errdefer allocator.free(user); + const project = try std.fs.path.join(allocator, &.{ cwd, ".panto", "models.toml" }); + errdefer allocator.free(project); + const local = try std.fs.path.join(allocator, &.{ cwd, ".panto", "models.local.toml" }); + return .{ .base = base, .user = user, .project = project, .local = local }; +} + +/// Load and merge the four `models.toml` layers (base → user → project → +/// local). Missing files are skipped silently. +pub fn load( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + cwd: []const u8, +) !Models { + const paths = try layerPaths(allocator, environ_map, cwd); + defer paths.deinit(allocator); + return loadFromPaths(allocator, io, &.{ paths.base, paths.user, paths.project, paths.local }); +} + +/// Load and merge explicit `models.toml` layer paths, lowest precedence +/// first. Missing files are skipped. +pub fn loadFromPaths(allocator: Allocator, io: Io, paths: []const []const u8) !Models { + const merged = try tvalue.createDocument(allocator); + defer merged.deinit(); + + for (paths) |path| { + const bytes = readFileAlloc(allocator, io, path) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + defer allocator.free(bytes); + + const doc = parseDoc(allocator, bytes) catch return error.InvalidModelsToml; + defer doc.deinit(); + try mergeTable(merged.allocator(), merged.root, doc.root); + } + var models: Models = .{ .defs = ModelRegistry.init(allocator), .pricing = Registry.init(allocator), }; errdefer models.deinit(); + try ingestDocument(&models, merged); + return models; +} + +/// Load one `models.toml` file. A missing file yields empty registries +/// (no error). Parse errors return `error.InvalidModelsToml`. +pub fn loadFromPath(allocator: Allocator, io: Io, path: []const u8) !Models { + return loadFromPaths(allocator, io, &.{path}); +} +fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) ![]u8 { const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { - error.FileNotFound => return models, // empty; perfectly fine. - else => return err, + error.FileNotFound => return error.FileNotFound, + else => return error.ModelsReadFailed, }; defer file.close(io); - const file_len = try file.length(io); - const bytes = try allocator.alloc(u8, @intCast(file_len)); - defer allocator.free(bytes); + const len = try file.length(io); + const bytes = try allocator.alloc(u8, @intCast(len)); + errdefer allocator.free(bytes); _ = try file.readPositionalAll(io, bytes, 0); - - try parseInto(&models, bytes); - return models; + return bytes; } -/// Parse a TOML string into the given registries. Useful for tests. -pub fn parseInto(models: *Models, source: []const u8) !void { - const alloc = models.defs.allocator; - const result = toml.parseWithError(alloc, source, .{}); - switch (result) { +fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document { + const result = toml.parseWithError(allocator, source, .{}); + return switch (result) { .err => |e| { if (!@import("builtin").is_test) { std.log.err( @@ -197,14 +295,57 @@ pub fn parseInto(models: *Models, source: []const u8) !void { } return error.InvalidModelsToml; }, - .ok => |doc| { - defer { - var d = doc; - d.deinit(); + .ok => |doc| doc, + }; +} + +fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void { + std.debug.assert(dst.* == .table); + if (src.* != .table) return; + + var it = toml.tableIterator(src); + while (it.next()) |entry| { + const existing = dst.get(entry.key); + if (existing != null and existing.?.* == .table and entry.value.* == .table) { + try mergeTable(alloc, @constCast(existing.?), entry.value); + } else { + const copy = try cloneValue(alloc, entry.value); + const key_copy = try alloc.dupe(u8, entry.key); + try tvalue.tableSet(alloc, dst, key_copy, copy); + } + } +} + +fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value { + const out = try alloc.create(toml.Value); + switch (src.*) { + .table => { + out.* = .{ .table = .{} }; + var it = toml.tableIterator(src); + while (it.next()) |entry| { + const child = try cloneValue(alloc, entry.value); + const key_copy = try alloc.dupe(u8, entry.key); + try tvalue.tableSet(alloc, out, key_copy, child); + } + }, + .array => |*a| { + out.* = .{ .array = .{} }; + for (a.items.items) |*item| { + const child = try cloneValue(alloc, item); + try tvalue.arrayAppend(alloc, out, child.*); } - try ingestDocument(models, doc); }, + .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) }, + else => out.* = src.*, } + return out; +} + +/// Parse a TOML string into the given registries. Useful for tests. +pub fn parseInto(models: *Models, source: []const u8) !void { + const doc = try parseDoc(models.defs.allocator, source); + defer doc.deinit(); + try ingestDocument(models, doc); } fn ingestDocument(models: *Models, doc: *toml.Document) !void { @@ -253,6 +394,15 @@ fn ingestModel( break :blk .default; }; + const context_window: ?u32 = blk: { + if (v.get("context_window")) |cw| { + if (cw.asI64()) |i| { + if (i > 0) break :blk @intCast(i); + } + } + break :blk null; + }; + const max_tokens: ?u32 = blk: { if (v.get("max_tokens")) |mt| { if (mt.asI64()) |i| { @@ -320,6 +470,7 @@ fn ingestModel( .alias = alias_copy, .model = model_copy, .reasoning = reasoning, + .context_window = context_window, .max_tokens = max_tokens, .api_version = api_version_copy, .thinking = thinking, @@ -370,6 +521,7 @@ test "parseInto: model defs carry wire name and knobs, keyed by provider.alias" \\[anthropic.sonnet] \\model = "claude-sonnet-4-20250514" \\reasoning = "high" + \\context_window = 200000 \\max_tokens = 8192 \\input = 3.0 \\output = 15.0 @@ -384,6 +536,7 @@ test "parseInto: model defs carry wire name and knobs, keyed by provider.alias" const sonnet = models.defs.get("anthropic", "sonnet").?; try testing.expectEqualStrings("claude-sonnet-4-20250514", sonnet.model); try testing.expectEqual(ReasoningEffort.high, sonnet.reasoning); + try testing.expectEqual(@as(?u32, 200000), sonnet.context_window); try testing.expectEqual(@as(?u32, 8192), sonnet.max_tokens); const gpt = models.defs.get("openai", "gpt").?; @@ -470,6 +623,28 @@ test "ModelRegistry.get: returns null for unknown alias" { try testing.expect(models.defs.get("openai", "sonnet") == null); } +test "basePath: PANTO_HOME wins" { + const a = testing.allocator; + var env: std.process.Environ.Map = .init(a); + defer env.deinit(); + try env.put("PANTO_HOME", "/tmp/panto-home"); + try env.put("XDG_DATA_HOME", "/ignored"); + const got = try basePath(a, &env); + defer a.free(got); + try testing.expectEqualStrings("/tmp/panto-home/models.toml", got); +} + +test "basePath: XDG_DATA_HOME falls back before HOME" { + const a = testing.allocator; + var env: std.process.Environ.Map = .init(a); + defer env.deinit(); + try env.put("XDG_DATA_HOME", "/custom/data"); + try env.put("HOME", "/ignored"); + const got = try basePath(a, &env); + defer a.free(got); + try testing.expectEqualStrings("/custom/data/panto/models.toml", got); +} + test "configPath: XDG_CONFIG_HOME wins" { const a = testing.allocator; var env: std.process.Environ.Map = .init(a); @@ -491,6 +666,46 @@ test "configPath: falls back to HOME/.config" { try testing.expectEqualStrings("/home/user/.config/panto/models.toml", got); } +test "loadFromPaths: layers merge with later model fields overriding earlier ones" { + const a = testing.allocator; + const io = testing.io; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const tmp_n = try tmp.dir.realPath(io, &path_buf); + const tmp_path = path_buf[0..tmp_n]; + + const base = try std.fs.path.join(a, &.{ tmp_path, "base.toml" }); + defer a.free(base); + try tmp.dir.writeFile(io, .{ .sub_path = "base.toml", .data = + \\[openai.gpt-4o] + \\context_window = 128000 + \\max_tokens = 16384 + \\input = 2.5 + \\output = 10 + }); + + const project = try std.fs.path.join(a, &.{ tmp_path, "project.toml" }); + defer a.free(project); + try tmp.dir.writeFile(io, .{ .sub_path = "project.toml", .data = + \\[openai.gpt-4o] + \\max_tokens = 8192 + \\cache_write = 0 + }); + + var models = try loadFromPaths(a, io, &.{ base, project }); + defer models.deinit(); + + const def = models.defs.get("openai", "gpt-4o").?; + try testing.expectEqual(@as(?u32, 128000), def.context_window); + try testing.expectEqual(@as(?u32, 8192), def.max_tokens); + const price = models.pricing.get("openai", "gpt-4o").?; + try testing.expectEqual(@as(?u64, 250), price.input); + try testing.expectEqual(@as(?u64, 1000), price.output); + try testing.expectEqual(@as(?u64, 0), price.cache_write); +} + test "parseInto: anthropic thinking fields parse correctly" { var models = emptyModels(testing.allocator); defer models.deinit(); 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