//! Loader for `~/.config/panto/models.toml`. //! //! Schema: //! //! [.] //! input = # USD per million tokens (fresh input) //! output = # USD per million tokens //! cache_read = # USD per million tokens (optional; default "unknown") //! cache_write = # 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. //! //! `` is `openai` or `anthropic` (matching pantograph's API //! styles). `` 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); }