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/models_toml.zig | |
| parent | 02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (diff) | |
model registry sync via models.dev
Diffstat (limited to 'src/models_toml.zig')
| -rw-r--r-- | src/models_toml.zig | 269 |
1 files changed, 242 insertions, 27 deletions
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(); |
