diff options
| author | T <t@tjp.lol> | 2026-05-27 12:45:20 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-27 16:21:52 -0600 |
| commit | 6545cdfd8f2bc865aa06a2b5515056daf58ba111 (patch) | |
| tree | 5393cefda42dad12eb90612c6b7ff3c50d8eb800 /src/models_toml.zig | |
| parent | b1a155273662d7dc2ea1fe0cfc43c1741ed30b6d (diff) | |
session files
Diffstat (limited to 'src/models_toml.zig')
| -rw-r--r-- | src/models_toml.zig | 291 |
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(®, 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(®, 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(®, 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(®, 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(®, 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(®, "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); +} |
