diff options
| author | t <t@tjp.lol> | 2026-06-15 16:14:55 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-15 16:15:10 -0600 |
| commit | d9d4131cb321a9ce29b000cd7aed8ced9a18efc1 (patch) | |
| tree | 5cc65cf6b0d226daa716f00aa6f667e8cf6dfa61 /src | |
| parent | 02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (diff) | |
model registry sync via models.dev
Diffstat (limited to 'src')
| -rw-r--r-- | src/config_file.zig | 35 | ||||
| -rw-r--r-- | src/luarocks_runtime.zig | 2 | ||||
| -rw-r--r-- | src/main.zig | 15 | ||||
| -rw-r--r-- | src/models_toml.zig | 269 | ||||
| -rw-r--r-- | src/subcommand.zig | 275 |
5 files changed, 559 insertions, 37 deletions
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 = <bool>` (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 = <string>` 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 `<provider_name>:<model_alias>`. //! - `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.<name>` 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 `<provider_name>.<alias>`, where //! `<provider_name>` matches a provider defined in `config.toml`'s @@ -8,8 +18,9 @@ //! Schema: //! //! [<provider>.<alias>] -//! model = <string> # wire model id; defaults to <alias> if omitted -//! max_tokens = <int> # per-request output token cap +//! model = <string> # wire model id; defaults to <alias> if omitted +//! context_window = <int> # total input context, for metadata/UI +//! max_tokens = <int> # per-request output token cap //! # pricing (all optional; USD per million tokens): //! input = <float> //! output = <float> @@ -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); } - try ingestDocument(models, doc); }, + .array => |*a| { + out.* = .{ .array = .{} }; + for (a.items.items) |*item| { + const child = try cloneValue(alloc, item); + try tvalue.arrayAppend(alloc, out, child.*); + } + }, + .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 <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` // --------------------------------------------------------------------------- |
