summaryrefslogtreecommitdiff
path: root/src/models_toml.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/models_toml.zig')
-rw-r--r--src/models_toml.zig269
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();