//! Layered loader for `models.toml` — model aliases and per-model pricing. //! //! Four files are read and merged, lowest precedence first: //! //! 1. base — `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml` //! 2. user — `${XDG_CONFIG_HOME:-$HOME/.config}/panto/models.toml` //! 3. project — `./.panto/models.toml` //! 4. local — `./.panto/models.local.toml` //! //! Tables merge recursively; later scalar values overwrite earlier ones. //! //! A model entry is keyed by `.`, where //! `` matches a provider defined in `config.toml`'s //! `[providers.]` table, and `` is the short name the user //! references via `:` (e.g. `anthropic:sonnet`). //! //! Schema: //! //! [.] //! model = # wire model id; defaults to if omitted //! context_window = # total input context, for metadata/UI //! max_tokens = # per-request output token cap //! # pricing (all optional; USD per million tokens): //! input = //! output = //! cache_read = //! cache_write = //! //! # openai_chat-only knobs: //! reasoning = # default | off | minimal | low | medium | high //! //! # anthropic_messages-only knobs: //! api_version = # Anthropic-Version header; default "2023-06-01" //! thinking = # disabled | enabled | adaptive (default: disabled, //! # or adaptive when `effort` is present) //! effort = # low | medium | high | xhigh | max (default: medium) //! # only used when thinking = "adaptive" //! thinking_budget_tokens = # max reasoning tokens for thinking = "enabled" //! # default: 32000; null falls back to max_tokens - 1 //! # ignored when thinking = "adaptive" or "disabled" //! thinking_interleaved = # send interleaved-thinking beta header (default: false) //! # only honoured when thinking = "enabled" //! //! 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.sonnet] //! model = "claude-sonnet-4-20250514" //! max_tokens = 8192 //! thinking = "enabled" //! thinking_budget_tokens = 16000 //! input = 3.0 //! output = 15.0 //! cache_read = 0.3 //! cache_write = 3.75 //! //! [anthropic.opus] //! model = "claude-opus-4-8" //! thinking = "adaptive" //! effort = "high" //! //! [openai.gpt] //! model = "gpt-4o" //! input = 2.5 //! output = 10.0 //! //! 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; const Io = std.Io; const toml = @import("toml"); const panto = @import("panto"); const tvalue = toml.value_mod; const panto_home = @import("panto_home.zig"); const toml_layer = @import("toml_layer.zig"); pub const Pricing = panto.Pricing; pub const Registry = panto.PricingRegistry; pub const ReasoningEffort = panto.ReasoningEffort; /// A resolved model definition: the wire model id plus per-model knobs. /// Strings are owned by the `ModelRegistry`. pub const Thinking = panto.Thinking; pub const Effort = panto.Effort; pub const ModelDef = struct { /// `` — matches a `[providers.]` in config.toml. provider: []const u8, /// `` — 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, /// Approximate total input context window, for picker/UI metadata. context_window: ?u32 = null, /// Per-request output token cap; null = use the provider/library default. max_tokens: ?u32, /// anthropic_messages only; null = use the library default. api_version: ?[]const u8, /// anthropic_messages only. Extended-thinking mode. thinking: Thinking, /// anthropic_messages only. Effort level for adaptive thinking. effort: Effort, /// anthropic_messages only. Manual thinking token budget; null = max_tokens - 1. thinking_budget_tokens: ?u32, /// anthropic_messages only. Whether to send the interleaved-thinking beta header. thinking_interleaved: bool, }; /// 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 base-layer path to `models.toml`: /// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`. /// Caller owns the returned slice. pub fn basePath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 { const home = try panto_home.homePath(allocator, environ_map); defer allocator.free(home); return try std.fs.path.join(allocator, &.{ home, "models.toml" }); } /// Resolve the user-layer path to `models.toml`. Caller owns the returned /// slice. pub fn userPath(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; } /// Back-compat alias for the user-layer path. pub fn configPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 { return userPath(allocator, environ_map); } const LayerPaths = struct { base: []u8, user: []u8, project: []u8, local: []u8, fn deinit(self: LayerPaths, allocator: Allocator) void { allocator.free(self.base); allocator.free(self.user); allocator.free(self.project); allocator.free(self.local); } }; fn layerPaths( allocator: Allocator, environ_map: *const std.process.Environ.Map, cwd: []const u8, ) !LayerPaths { const base = try basePath(allocator, environ_map); errdefer allocator.free(base); const user = try userPath(allocator, environ_map); errdefer allocator.free(user); const project = try std.fs.path.join(allocator, &.{ cwd, ".panto", "models.toml" }); errdefer allocator.free(project); const local = try std.fs.path.join(allocator, &.{ cwd, ".panto", "models.local.toml" }); return .{ .base = base, .user = user, .project = project, .local = local }; } /// Load and merge the four `models.toml` layers (base → user → project → /// local). Missing files are skipped silently. pub fn load( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, cwd: []const u8, ) !Models { const paths = try layerPaths(allocator, environ_map, cwd); defer paths.deinit(allocator); return loadFromPaths(allocator, io, &.{ paths.base, paths.user, paths.project, paths.local }); } /// Load and merge explicit `models.toml` layer paths, lowest precedence /// first. Missing files are skipped. pub fn loadFromPaths(allocator: Allocator, io: Io, paths: []const []const u8) !Models { const merged = try tvalue.createDocument(allocator); defer merged.deinit(); for (paths) |path| { const bytes = toml_layer.readFileAlloc(allocator, io, path) catch |err| switch (err) { error.FileNotFound => continue, error.ReadFailed => return error.ModelsReadFailed, error.Canceled => return error.Canceled, error.OutOfMemory => return error.OutOfMemory, }; defer allocator.free(bytes); const doc = parseDoc(allocator, bytes) catch return error.InvalidModelsToml; defer doc.deinit(); try toml_layer.mergeTable(merged.allocator(), merged.root, doc.root); } var models: Models = .{ .defs = ModelRegistry.init(allocator), .pricing = Registry.init(allocator), }; errdefer models.deinit(); try ingestDocument(&models, merged); return models; } /// Load one `models.toml` file. A missing file yields empty registries /// (no error). Parse errors return `error.InvalidModelsToml`. pub fn loadFromPath(allocator: Allocator, io: Io, path: []const u8) !Models { return loadFromPaths(allocator, io, &.{path}); } fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document { const result = toml.parseWithError(allocator, source, .{}); return switch (result) { .err => |e| { 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| doc, }; } /// Parse a TOML string into the given registries. Useful for tests. pub fn parseInto(models: *Models, source: []const u8) !void { const doc = try parseDoc(models.defs.allocator, source); defer doc.deinit(); try ingestDocument(models, doc); } 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 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; 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 context_window: ?u32 = blk: { if (v.get("context_window")) |cw| { if (cw.asI64()) |i| { if (i > 0) break :blk @intCast(i); } } break :blk null; }; 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; }; const has_effort = v.get("effort") != null; const thinking: Thinking = blk: { if (v.get("thinking")) |t| { if (t.asString()) |s| { break :blk std.meta.stringToEnum(Thinking, s) orelse .disabled; } } break :blk if (has_effort) .adaptive else .disabled; }; const effort: Effort = blk: { if (v.get("effort")) |e| { if (e.asString()) |s| { break :blk std.meta.stringToEnum(Effort, s) orelse .medium; } } break :blk .medium; }; const thinking_budget_tokens: ?u32 = blk: { if (v.get("thinking_budget_tokens")) |bt| { if (bt.asI64()) |i| { if (i > 0) break :blk @intCast(i); } } break :blk null; }; const thinking_interleaved: bool = blk: { if (v.get("thinking_interleaved")) |ti| { if (ti.asBool()) |b| break :blk b; } break :blk false; }; // 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, .context_window = context_window, .max_tokens = max_tokens, .api_version = api_version_copy, .thinking = thinking, .effort = effort, .thinking_budget_tokens = thinking_budget_tokens, .thinking_interleaved = thinking_interleaved, }); // 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"), .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` comes through as a known zero. fn readPriceField(table: *const toml.Value, name: []const u8) ?u64 { const field = table.get(name) orelse return null; if (field.asF64()) |f| return Pricing.fromDollarsPerMtok(f); if (field.asI64()) |i| return Pricing.fromDollarsPerMtok(@floatFromInt(i)); return null; } // ============================================================================= // Tests // ============================================================================= const testing = std.testing; 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.sonnet] \\model = "claude-sonnet-4-20250514" \\reasoning = "high" \\context_window = 200000 \\max_tokens = 8192 \\input = 3.0 \\output = 15.0 \\ \\[openai.gpt] \\model = "gpt-4o" \\input = 2.5 \\output = 10.0 ; try parseInto(&models, src); 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, 200000), sonnet.context_window); try testing.expectEqual(@as(?u32, 8192), sonnet.max_tokens); 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); // 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: omitted model defaults the wire name to the alias" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[openai.gpt-4o] \\input = 2.5 \\output = 10.0 ; try parseInto(&models, src); const def = models.defs.get("openai", "gpt-4o").?; try testing.expectEqualStrings("gpt-4o", def.model); } test "parseInto: pricing omissions stay null (unknown, not zero)" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[openai.gpt] \\model = "gpt-4o" \\input = 2.5 \\output = 10.0 ; 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 cache_write = 0 is a known zero" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[openai.gpt] \\model = "gpt-4o" \\input = 2.5 \\output = 10.0 \\cache_write = 0 ; 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 models = emptyModels(testing.allocator); defer models.deinit(); try testing.expectError(error.InvalidModelsToml, parseInto(&models, "not valid = =")); } test "loadFromPath: missing file returns empty registries, no error" { const io = testing.io; 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 "basePath: XDG_DATA_HOME falls back before HOME" { const a = testing.allocator; var env: std.process.Environ.Map = .init(a); defer env.deinit(); try env.put("XDG_DATA_HOME", "/custom/data"); try env.put("HOME", "/ignored"); const got = try basePath(a, &env); defer a.free(got); try testing.expectEqualStrings("/custom/data/panto/models.toml", got); } 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); } test "loadFromPaths: layers merge with later model fields overriding earlier ones" { const a = testing.allocator; const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const tmp_n = try tmp.dir.realPath(io, &path_buf); const tmp_path = path_buf[0..tmp_n]; const base = try std.fs.path.join(a, &.{ tmp_path, "base.toml" }); defer a.free(base); try tmp.dir.writeFile(io, .{ .sub_path = "base.toml", .data = \\[openai.gpt-4o] \\context_window = 128000 \\max_tokens = 16384 \\input = 2.5 \\output = 10 }); const project = try std.fs.path.join(a, &.{ tmp_path, "project.toml" }); defer a.free(project); try tmp.dir.writeFile(io, .{ .sub_path = "project.toml", .data = \\[openai.gpt-4o] \\max_tokens = 8192 \\cache_write = 0 }); var models = try loadFromPaths(a, io, &.{ base, project }); defer models.deinit(); const def = models.defs.get("openai", "gpt-4o").?; try testing.expectEqual(@as(?u32, 128000), def.context_window); try testing.expectEqual(@as(?u32, 8192), def.max_tokens); const price = models.pricing.get("openai", "gpt-4o").?; try testing.expectEqual(@as(?u64, 250), price.input); try testing.expectEqual(@as(?u64, 1000), price.output); try testing.expectEqual(@as(?u64, 0), price.cache_write); } test "parseInto: anthropic thinking fields parse correctly" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[anthropic.sonnet] \\model = "claude-sonnet-4-6" \\thinking = "enabled" \\thinking_budget_tokens = 16000 \\thinking_interleaved = true ; try parseInto(&models, src); const def = models.defs.get("anthropic", "sonnet").?; try testing.expectEqual(Thinking.enabled, def.thinking); try testing.expectEqual(Effort.medium, def.effort); // default try testing.expectEqual(@as(?u32, 16000), def.thinking_budget_tokens); try testing.expectEqual(true, def.thinking_interleaved); } test "parseInto: anthropic adaptive thinking with effort" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[anthropic.opus] \\model = "claude-opus-4-8" \\thinking = "adaptive" \\effort = "high" ; try parseInto(&models, src); const def = models.defs.get("anthropic", "opus").?; try testing.expectEqual(Thinking.adaptive, def.thinking); try testing.expectEqual(Effort.high, def.effort); try testing.expectEqual(@as(?u32, null), def.thinking_budget_tokens); // absent => null try testing.expectEqual(false, def.thinking_interleaved); // default } test "parseInto: anthropic effort implies adaptive thinking" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[anthropic.opus] \\model = "claude-opus-4-8" \\effort = "high" ; try parseInto(&models, src); const def = models.defs.get("anthropic", "opus").?; try testing.expectEqual(Thinking.adaptive, def.thinking); try testing.expectEqual(Effort.high, def.effort); } test "parseInto: explicit thinking overrides effort-implied adaptive thinking" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[anthropic.haiku] \\model = "claude-haiku-4-5-20251001" \\thinking = "disabled" \\effort = "high" ; try parseInto(&models, src); const def = models.defs.get("anthropic", "haiku").?; try testing.expectEqual(Thinking.disabled, def.thinking); try testing.expectEqual(Effort.high, def.effort); } test "parseInto: anthropic thinking disabled" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[anthropic.haiku] \\model = "claude-haiku-4-5-20251001" \\thinking = "disabled" ; try parseInto(&models, src); const def = models.defs.get("anthropic", "haiku").?; try testing.expectEqual(Thinking.disabled, def.thinking); } test "parseInto: anthropic thinking defaults when absent" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[anthropic.base] \\model = "claude-sonnet-4-20250514" ; try parseInto(&models, src); const def = models.defs.get("anthropic", "base").?; try testing.expectEqual(Thinking.disabled, def.thinking); try testing.expectEqual(Effort.medium, def.effort); try testing.expectEqual(@as(?u32, null), def.thinking_budget_tokens); try testing.expectEqual(false, def.thinking_interleaved); } test "parseInto: effort xhigh and max parse" { var models = emptyModels(testing.allocator); defer models.deinit(); const src = \\[anthropic.opus-xhigh] \\model = "claude-opus-4-7" \\thinking = "adaptive" \\effort = "xhigh" \\[anthropic.opus-max] \\model = "claude-opus-4-6" \\thinking = "adaptive" \\effort = "max" ; try parseInto(&models, src); try testing.expectEqual(Effort.xhigh, models.defs.get("anthropic", "opus-xhigh").?.effort); try testing.expectEqual(Effort.max, models.defs.get("anthropic", "opus-max").?.effort); }