summaryrefslogtreecommitdiff
path: root/src/models_toml.zig
blob: 5ccb3027c6e6183d4cbf0e46faef56a961ce7da1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
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);
}