summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config_file.zig35
-rw-r--r--src/luarocks_runtime.zig2
-rw-r--r--src/main.zig15
-rw-r--r--src/models_toml.zig269
-rw-r--r--src/subcommand.zig275
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`
// ---------------------------------------------------------------------------