From 1beefefc69beee214430eb5bd2528a4f5692d2a8 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 1 Jun 2026 08:21:00 -0600 Subject: Rename system extension layer to base for clarity Replace all references to the "system" layer with "base" to better reflect its role as the foundational extension/tool layer in panto's hierarchy. The layer hierarchy now consistently uses: project > user > base. Includes: - Update agent README and build.zig documentation - Refactor tool registry and config module to support layered lookups - Add TestHarness abstraction for cleaner test setup - Improve JSON serialization with wire-encoded tool names - Add glob pattern matching for tool/extension discovery --- src/models_toml.zig | 381 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 256 insertions(+), 125 deletions(-) (limited to 'src/models_toml.zig') 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 `.`, where +//! `` matches a provider defined in `config.toml`'s +//! `[providers.]` table, and `` is the short name the user +//! references via `:` (e.g. `anthropic:sonnet`). //! -//! [.] -//! 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") +//! 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. +//! [.] +//! model = # wire model id; defaults to if omitted +//! reasoning = # default | off | minimal | low | medium | high +//! max_tokens = # anthropic_messages only +//! api_version = # anthropic_messages only +//! # pricing (all optional; USD per million tokens): +//! input = +//! output = +//! cache_read = +//! cache_write = //! -//! `` 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). +//! 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 { + /// `` — 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, + /// 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(®, 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(®, 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); + 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, 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: 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(®, 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(®, 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(®, 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(®, "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" { -- cgit v1.3