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.zig375
1 files changed, 253 insertions, 122 deletions
diff --git a/src/models_toml.zig b/src/models_toml.zig
index 5ccb302..36d8c65 100644
--- a/src/models_toml.zig
+++ b/src/models_toml.zig
@@ -1,46 +1,46 @@
-//! Loader for `~/.config/panto/models.toml`.
+//! Loader for `models.toml` — model aliases and per-model pricing.
//!
-//! Schema:
+//! A model entry is keyed by `<provider_name>.<alias>`, where
+//! `<provider_name>` matches a provider defined in `config.toml`'s
+//! `[providers.<name>]` table, and `<alias>` is the short name the user
+//! references via `<provider_name>:<alias>` (e.g. `anthropic:sonnet`).
//!
-//! [<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")
+//! Schema:
//!
-//! 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>.<alias>]
+//! model = <string> # wire model id; defaults to <alias> if omitted
+//! reasoning = <string> # default | off | minimal | low | medium | high
+//! max_tokens = <int> # anthropic_messages only
+//! api_version = <string> # anthropic_messages only
+//! # pricing (all optional; USD per million tokens):
+//! input = <float>
+//! output = <float>
+//! cache_read = <float>
+//! cache_write = <float>
//!
-//! `<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).
+//! Pricing semantics are unchanged from the original models.toml: any
+//! omitted price field is "unknown" (null), not zero. Write `= 0`
+//! explicitly to declare a known-zero rate.
//!
//! Example:
//!
-//! [anthropic."claude-sonnet-4-20250514"]
+//! [anthropic.sonnet]
+//! model = "claude-sonnet-4-20250514"
+//! reasoning = "high"
+//! max_tokens = 8192
//! input = 3.0
//! output = 15.0
//! cache_read = 0.3
//! cache_write = 3.75
//!
-//! [openai.gpt-4o]
+//! [openai.gpt]
+//! model = "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.
+//! A referenced alias with no entry here is not an error: the alias is
+//! used verbatim as the wire model name (zero-config convenience), with
+//! default knobs and unknown pricing.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -51,6 +51,71 @@ const panto = @import("panto");
pub const Pricing = panto.pricing.Pricing;
pub const Registry = panto.pricing.Registry;
+pub const ReasoningEffort = panto.config.ReasoningEffort;
+
+/// A resolved model definition: the wire model id plus per-model knobs.
+/// Strings are owned by the `ModelRegistry`.
+pub const ModelDef = struct {
+ /// `<provider_name>` — matches a `[providers.<name>]` in config.toml.
+ provider: []const u8,
+ /// `<alias>` — the short reference name.
+ alias: []const u8,
+ /// Wire model id sent to the provider API. Equals `alias` when the
+ /// TOML omitted `model`.
+ model: []const u8,
+ reasoning: ReasoningEffort,
+ /// anthropic_messages only; null = use the provider/library default.
+ max_tokens: ?u32,
+ /// anthropic_messages only; null = use the library default.
+ api_version: ?[]const u8,
+};
+
+/// In-memory map of `(provider, alias) -> ModelDef`. Owns all strings.
+pub const ModelRegistry = struct {
+ allocator: Allocator,
+ entries: std.ArrayList(ModelDef),
+
+ pub fn init(allocator: Allocator) ModelRegistry {
+ return .{ .allocator = allocator, .entries = .empty };
+ }
+
+ pub fn deinit(self: *ModelRegistry) void {
+ for (self.entries.items) |e| {
+ self.allocator.free(e.provider);
+ self.allocator.free(e.alias);
+ self.allocator.free(e.model);
+ if (e.api_version) |v| self.allocator.free(v);
+ }
+ self.entries.deinit(self.allocator);
+ }
+
+ pub fn count(self: *const ModelRegistry) usize {
+ return self.entries.items.len;
+ }
+
+ /// Look up a model definition by provider name + alias.
+ pub fn get(self: *const ModelRegistry, provider: []const u8, alias: []const u8) ?ModelDef {
+ for (self.entries.items) |e| {
+ if (std.mem.eql(u8, e.provider, provider) and std.mem.eql(u8, e.alias, alias)) {
+ return e;
+ }
+ }
+ return null;
+ }
+};
+
+/// Everything `models.toml` yields: model definitions and pricing. The
+/// two registries are independent (pricing keys on the *wire* model id;
+/// model defs key on the alias) but parsed in one pass.
+pub const Models = struct {
+ defs: ModelRegistry,
+ pricing: Registry,
+
+ pub fn deinit(self: *Models) void {
+ self.defs.deinit();
+ self.pricing.deinit();
+ }
+};
/// Resolve the absolute path to `models.toml`. Honors `XDG_CONFIG_HOME`,
/// falling back to `$HOME/.config`. Caller owns the returned slice.
@@ -64,21 +129,17 @@ pub fn configPath(allocator: Allocator, environ_map: *const std.process.Environ.
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();
+/// 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 {
+ var models: Models = .{
+ .defs = ModelRegistry.init(allocator),
+ .pricing = Registry.init(allocator),
+ };
+ errdefer models.deinit();
const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
- error.FileNotFound => return reg, // empty registry; perfectly fine.
+ error.FileNotFound => return models, // empty; perfectly fine.
else => return err,
};
defer file.close(io);
@@ -88,18 +149,16 @@ pub fn loadFromPath(
defer allocator.free(bytes);
_ = try file.readPositionalAll(io, bytes, 0);
- try parseInto(&reg, bytes);
- return reg;
+ try parseInto(&models, bytes);
+ return models;
}
-/// Parse a TOML string into the given registry. Useful for tests.
-pub fn parseInto(reg: *Registry, source: []const u8) !void {
- const alloc = reg.allocator;
+/// 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) {
.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}",
@@ -113,31 +172,98 @@ pub fn parseInto(reg: *Registry, source: []const u8) !void {
var d = doc;
d.deinit();
}
- try ingestDocument(reg, doc);
+ try ingestDocument(models, doc);
},
}
}
-fn ingestDocument(reg: *Registry, doc: *toml.Document) !void {
- // Root is a table of provider -> table of model -> { input, output, ... }.
+fn ingestDocument(models: *Models, doc: *toml.Document) !void {
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;
+
+ var alias_it = toml.tableIterator(provider_val);
+ while (alias_it.next()) |alias_entry| {
+ const alias = alias_entry.key;
+ const v: *const toml.Value = alias_entry.value;
if (v.* != .table) continue;
- const pricing = pricingFromValue(v);
- try reg.set(provider, model, pricing);
+
+ try ingestModel(models, provider, alias, v);
}
}
}
+fn ingestModel(
+ models: *Models,
+ provider: []const u8,
+ alias: []const u8,
+ v: *const toml.Value,
+) !void {
+ const alloc = models.defs.allocator;
+
+ // Wire model id: explicit `model`, else the alias itself.
+ const wire_model: []const u8 = blk: {
+ if (v.get("model")) |m| {
+ if (m.asString()) |s| break :blk s;
+ }
+ break :blk alias;
+ };
+
+ const reasoning: ReasoningEffort = blk: {
+ if (v.get("reasoning")) |r| {
+ if (r.asString()) |s| {
+ break :blk std.meta.stringToEnum(ReasoningEffort, s) orelse .default;
+ }
+ }
+ break :blk .default;
+ };
+
+ const max_tokens: ?u32 = blk: {
+ if (v.get("max_tokens")) |mt| {
+ if (mt.asI64()) |i| {
+ if (i > 0) break :blk @intCast(i);
+ }
+ }
+ break :blk null;
+ };
+
+ const api_version_src: ?[]const u8 = blk: {
+ if (v.get("api_version")) |av| {
+ if (av.asString()) |s| break :blk s;
+ }
+ break :blk null;
+ };
+
+ // Own all the strings.
+ const provider_copy = try alloc.dupe(u8, provider);
+ errdefer alloc.free(provider_copy);
+ const alias_copy = try alloc.dupe(u8, alias);
+ errdefer alloc.free(alias_copy);
+ const model_copy = try alloc.dupe(u8, wire_model);
+ errdefer alloc.free(model_copy);
+ const api_version_copy: ?[]const u8 = if (api_version_src) |s| try alloc.dupe(u8, s) else null;
+ errdefer if (api_version_copy) |s| alloc.free(s);
+
+ try models.defs.entries.append(alloc, .{
+ .provider = provider_copy,
+ .alias = alias_copy,
+ .model = model_copy,
+ .reasoning = reasoning,
+ .max_tokens = max_tokens,
+ .api_version = api_version_copy,
+ });
+
+ // Pricing keys on the *wire* model id so usage records (which carry
+ // the wire model) resolve directly.
+ const pricing = pricingFromValue(v);
+ try models.pricing.set(provider, wire_model, pricing);
+}
+
fn pricingFromValue(v: *const toml.Value) Pricing {
return .{
.input = readPriceField(v, "input"),
@@ -148,12 +274,9 @@ fn pricingFromValue(v: *const toml.Value) Pricing {
}
/// 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.
+/// interpret as a price. An explicit `0` comes through as a known zero.
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;
@@ -165,108 +288,116 @@ fn readPriceField(table: *const toml.Value, name: []const u8) ?u64 {
const testing = std.testing;
-test "parseInto: two providers, multiple models" {
- var reg = Registry.init(testing.allocator);
- defer reg.deinit();
+fn emptyModels(a: Allocator) Models {
+ return .{ .defs = ModelRegistry.init(a), .pricing = Registry.init(a) };
+}
+
+test "parseInto: model defs carry wire name and knobs, keyed by provider.alias" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
const src =
- \\[anthropic."claude-sonnet-4-20250514"]
+ \\[anthropic.sonnet]
+ \\model = "claude-sonnet-4-20250514"
+ \\reasoning = "high"
+ \\max_tokens = 8192
\\input = 3.0
\\output = 15.0
- \\cache_read = 0.3
- \\cache_write = 3.75
\\
- \\[openai.gpt-4o]
+ \\[openai.gpt]
+ \\model = "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);
+ try parseInto(&models, 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 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, 8192), sonnet.max_tokens);
- 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 gpt = models.defs.get("openai", "gpt").?;
+ try testing.expectEqualStrings("gpt-4o", gpt.model);
+ try testing.expectEqual(ReasoningEffort.default, gpt.reasoning);
+ try testing.expectEqual(@as(?u32, null), gpt.max_tokens);
- 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);
+ // Pricing keyed on the wire model id.
+ const sonnet_price = models.pricing.get("anthropic", "claude-sonnet-4-20250514").?;
+ try testing.expectEqual(@as(?u64, 300), sonnet_price.input);
+ try testing.expectEqual(@as(?u64, 1500), sonnet_price.output);
}
-test "parseInto: integer values are accepted (user writes `3` not `3.0`)" {
- var reg = Registry.init(testing.allocator);
- defer reg.deinit();
+test "parseInto: omitted model defaults the wire name to the alias" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
const src =
\\[openai.gpt-4o]
- \\input = 3
- \\output = 15
+ \\input = 2.5
+ \\output = 10.0
;
- 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);
+ try parseInto(&models, src);
+
+ const def = models.defs.get("openai", "gpt-4o").?;
+ try testing.expectEqualStrings("gpt-4o", def.model);
}
-test "parseInto: missing optional fields stay null (unknown, not zero)" {
- var reg = Registry.init(testing.allocator);
- defer reg.deinit();
+test "parseInto: pricing omissions stay null (unknown, not zero)" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
const src =
- \\[openai.gpt-4o]
+ \\[openai.gpt]
+ \\model = "gpt-4o"
\\input = 2.5
\\output = 10.0
;
- try parseInto(&reg, src);
- const p = reg.get("openai", "gpt-4o").?;
+ try parseInto(&models, src);
+ const p = models.pricing.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();
+test "parseInto: explicit cache_write = 0 is a known zero" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
const src =
- \\[openai.gpt-4o]
+ \\[openai.gpt]
+ \\model = "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 parseInto(&models, src);
+ const p = models.pricing.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 = ="));
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
+ try testing.expectError(error.InvalidModelsToml, parseInto(&models, "not valid = ="));
}
-test "loadFromPath: missing file returns empty registry, no error" {
+test "loadFromPath: missing file returns empty registries, 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());
+ var models = try loadFromPath(testing.allocator, io, "/nonexistent/models.toml");
+ defer models.deinit();
+ try testing.expectEqual(@as(usize, 0), models.defs.count());
+ try testing.expectEqual(@as(usize, 0), models.pricing.count());
+}
+
+test "ModelRegistry.get: returns null for unknown alias" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
+ const src =
+ \\[anthropic.sonnet]
+ \\model = "claude-sonnet-4-20250514"
+ ;
+ try parseInto(&models, src);
+ try testing.expect(models.defs.get("anthropic", "opus") == null);
+ try testing.expect(models.defs.get("openai", "sonnet") == null);
}
test "configPath: XDG_CONFIG_HOME wins" {