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.zig291
1 files changed, 291 insertions, 0 deletions
diff --git a/src/models_toml.zig b/src/models_toml.zig
new file mode 100644
index 0000000..5ccb302
--- /dev/null
+++ b/src/models_toml.zig
@@ -0,0 +1,291 @@
+//! Loader for `~/.config/panto/models.toml`.
+//!
+//! Schema:
+//!
+//! [<provider>.<model>]
+//! input = <float> # USD per million tokens (fresh input)
+//! output = <float> # USD per million tokens
+//! cache_read = <float> # USD per million tokens (optional; default "unknown")
+//! cache_write = <float> # USD per million tokens (optional; default "unknown")
+//!
+//! All four price fields are optional at the parse layer. Any field
+//! omitted from the TOML comes through as `null` in the in-memory
+//! `Pricing`, which means "unknown price for this token category" —
+//! NOT "zero." If the model later reports usage in an unknown
+//! category (e.g. gpt-4o reads from prompt cache but the TOML omitted
+//! `cache_read`), session cost degenerates to "unknown" rather than
+//! silently treating that usage as free. To declare a category as a
+//! known zero (e.g. OpenAI doesn't bill a cache-write rate), write
+//! `cache_write = 0` explicitly.
+//!
+//! `<provider>` is `openai` or `anthropic` (matching pantograph's API
+//! styles). `<model>` is the model id pantograph sends to the API. Both
+//! are TOML "dotted-key" path segments; quote them if they contain
+//! characters TOML doesn't allow bare (`-`, `.`, `:` etc. — `-` is OK
+//! bare; `.` requires quoting since dots separate path segments).
+//!
+//! Example:
+//!
+//! [anthropic."claude-sonnet-4-20250514"]
+//! input = 3.0
+//! output = 15.0
+//! cache_read = 0.3
+//! cache_write = 3.75
+//!
+//! [openai.gpt-4o]
+//! input = 2.5
+//! output = 10.0
+//!
+//! The model id in the section header may also be unquoted when it
+//! contains no `.` or other reserved chars (e.g. `gpt-4o`); pantograph
+//! reads either form. We do not currently bake in default pricing —
+//! a missing entry (or a present entry with missing fields) surfaces
+//! as "unknown cost" and the display layer formats accordingly.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+const toml = @import("toml");
+const panto = @import("panto");
+
+pub const Pricing = panto.pricing.Pricing;
+pub const Registry = panto.pricing.Registry;
+
+/// 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 {
+ if (environ_map.get("XDG_CONFIG_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, ".config", "panto", "models.toml" });
+ }
+ return error.NoHomeDirectory;
+}
+
+/// Load `models.toml` into a fresh `Registry`. If the file does not
+/// exist, returns an empty registry (no error — missing config is fine).
+///
+/// On parse errors, returns `error.InvalidModelsToml` after logging the
+/// line/column of the first error.
+pub fn loadFromPath(
+ allocator: Allocator,
+ io: Io,
+ path: []const u8,
+) !Registry {
+ var reg = Registry.init(allocator);
+ errdefer reg.deinit();
+
+ const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
+ error.FileNotFound => return reg, // empty registry; perfectly fine.
+ else => return err,
+ };
+ defer file.close(io);
+
+ const file_len = try file.length(io);
+ const bytes = try allocator.alloc(u8, @intCast(file_len));
+ defer allocator.free(bytes);
+ _ = try file.readPositionalAll(io, bytes, 0);
+
+ try parseInto(&reg, bytes);
+ return reg;
+}
+
+/// Parse a TOML string into the given registry. Useful for tests.
+pub fn parseInto(reg: *Registry, source: []const u8) !void {
+ const alloc = reg.allocator;
+ const result = toml.parseWithError(alloc, source, .{});
+ switch (result) {
+ .err => |e| {
+ // Silenced in tests so the test runner doesn't flag the
+ // expected-failure case as a real error.
+ if (!@import("builtin").is_test) {
+ std.log.err(
+ "models.toml: parse error at line {d}, column {d}: {s}",
+ .{ e.line, e.column, e.message },
+ );
+ }
+ return error.InvalidModelsToml;
+ },
+ .ok => |doc| {
+ defer {
+ var d = doc;
+ d.deinit();
+ }
+ try ingestDocument(reg, doc);
+ },
+ }
+}
+
+fn ingestDocument(reg: *Registry, doc: *toml.Document) !void {
+ // Root is a table of provider -> table of model -> { input, output, ... }.
+ const root_val: *const toml.Value = doc.root;
+ if (root_val.* != .table) return;
+ var provider_it = toml.tableIterator(root_val);
+ while (provider_it.next()) |provider_entry| {
+ const provider = provider_entry.key;
+ const provider_val: *const toml.Value = provider_entry.value;
+ if (provider_val.* != .table) continue;
+ var model_it = toml.tableIterator(provider_val);
+ while (model_it.next()) |model_entry| {
+ const model = model_entry.key;
+ const v: *const toml.Value = model_entry.value;
+ if (v.* != .table) continue;
+ const pricing = pricingFromValue(v);
+ try reg.set(provider, model, pricing);
+ }
+ }
+}
+
+fn pricingFromValue(v: *const toml.Value) Pricing {
+ return .{
+ .input = readPriceField(v, "input"),
+ .output = readPriceField(v, "output"),
+ .cache_read = readPriceField(v, "cache_read"),
+ .cache_write = readPriceField(v, "cache_write"),
+ };
+}
+
+/// Returns `null` if the field is absent or has a type we can't
+/// interpret as a price. An explicit `0` (or `0.0`) comes through as a
+/// known zero, not `null` — callers rely on that distinction.
+fn readPriceField(table: *const toml.Value, name: []const u8) ?u64 {
+ const field = table.get(name) orelse return null;
+ // Accept either floats (the natural form) or integers (the user
+ // wrote `3` instead of `3.0`).
+ if (field.asF64()) |f| return Pricing.fromDollarsPerMtok(f);
+ if (field.asI64()) |i| return Pricing.fromDollarsPerMtok(@floatFromInt(i));
+ return null;
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+const testing = std.testing;
+
+test "parseInto: two providers, multiple models" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ const src =
+ \\[anthropic."claude-sonnet-4-20250514"]
+ \\input = 3.0
+ \\output = 15.0
+ \\cache_read = 0.3
+ \\cache_write = 3.75
+ \\
+ \\[openai.gpt-4o]
+ \\input = 2.5
+ \\output = 10.0
+ \\cache_read = 1.25
+ \\
+ \\[openai.gpt-4o-mini]
+ \\input = 0.15
+ \\output = 0.6
+ ;
+ try parseInto(&reg, src);
+
+ const anth = reg.get("anthropic", "claude-sonnet-4-20250514").?;
+ try testing.expectEqual(@as(?u64, 300), anth.input);
+ try testing.expectEqual(@as(?u64, 1500), anth.output);
+ try testing.expectEqual(@as(?u64, 30), anth.cache_read);
+ try testing.expectEqual(@as(?u64, 375), anth.cache_write);
+
+ const oa = reg.get("openai", "gpt-4o").?;
+ try testing.expectEqual(@as(?u64, 250), oa.input);
+ try testing.expectEqual(@as(?u64, 1000), oa.output);
+ try testing.expectEqual(@as(?u64, 125), oa.cache_read);
+ // cache_write absent in source — stays unknown, NOT silently 0.
+ try testing.expectEqual(@as(?u64, null), oa.cache_write);
+
+ const mini = reg.get("openai", "gpt-4o-mini").?;
+ try testing.expectEqual(@as(?u64, 15), mini.input);
+ try testing.expectEqual(@as(?u64, 60), mini.output);
+ try testing.expectEqual(@as(?u64, null), mini.cache_read);
+ try testing.expectEqual(@as(?u64, null), mini.cache_write);
+}
+
+test "parseInto: integer values are accepted (user writes `3` not `3.0`)" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ const src =
+ \\[openai.gpt-4o]
+ \\input = 3
+ \\output = 15
+ ;
+ try parseInto(&reg, src);
+ const p = reg.get("openai", "gpt-4o").?;
+ try testing.expectEqual(@as(?u64, 300), p.input);
+ try testing.expectEqual(@as(?u64, 1500), p.output);
+}
+
+test "parseInto: missing optional fields stay null (unknown, not zero)" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ const src =
+ \\[openai.gpt-4o]
+ \\input = 2.5
+ \\output = 10.0
+ ;
+ try parseInto(&reg, src);
+ const p = reg.get("openai", "gpt-4o").?;
+ try testing.expectEqual(@as(?u64, null), p.cache_read);
+ try testing.expectEqual(@as(?u64, null), p.cache_write);
+}
+
+test "parseInto: explicit 0 is a known zero, distinct from omission" {
+ // OpenAI doesn't charge for cache writes. Writing `cache_write = 0`
+ // in the TOML must produce a known 0 — not null — so cost stays
+ // computable on turns that report cache_write usage.
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ const src =
+ \\[openai.gpt-4o]
+ \\input = 2.5
+ \\output = 10.0
+ \\cache_read = 1.25
+ \\cache_write = 0
+ ;
+ try parseInto(&reg, src);
+ const p = reg.get("openai", "gpt-4o").?;
+ try testing.expectEqual(@as(?u64, 0), p.cache_write);
+}
+
+test "parseInto: malformed TOML returns InvalidModelsToml" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+ try testing.expectError(error.InvalidModelsToml, parseInto(&reg, "this is not valid toml = ="));
+}
+
+test "loadFromPath: missing file returns empty registry, no error" {
+ const io = testing.io;
+ var reg = try loadFromPath(testing.allocator, io, "/nonexistent/path/models.toml");
+ defer reg.deinit();
+ try testing.expectEqual(@as(usize, 0), reg.count());
+}
+
+test "configPath: XDG_CONFIG_HOME wins" {
+ const a = testing.allocator;
+ var env: std.process.Environ.Map = .init(a);
+ defer env.deinit();
+ try env.put("XDG_CONFIG_HOME", "/custom/cfg");
+ try env.put("HOME", "/ignored");
+ const got = try configPath(a, &env);
+ defer a.free(got);
+ try testing.expectEqualStrings("/custom/cfg/panto/models.toml", got);
+}
+
+test "configPath: falls back to HOME/.config" {
+ const a = testing.allocator;
+ var env: std.process.Environ.Map = .init(a);
+ defer env.deinit();
+ try env.put("HOME", "/home/user");
+ const got = try configPath(a, &env);
+ defer a.free(got);
+ try testing.expectEqualStrings("/home/user/.config/panto/models.toml", got);
+}