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/config_file.zig | 977 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 977 insertions(+) create mode 100644 src/config_file.zig (limited to 'src/config_file.zig') diff --git a/src/config_file.zig b/src/config_file.zig new file mode 100644 index 0000000..533eb48 --- /dev/null +++ b/src/config_file.zig @@ -0,0 +1,977 @@ +//! Layered `config.toml` loader for the panto CLI. +//! +//! Four files are read and merged, lowest precedence first: +//! +//! 1. base — `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml` +//! 2. user — `${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml` +//! 3. project — `./.panto/config.toml` +//! 4. local — `./.panto/config.local.toml` +//! +//! The `local` layer is intended to be git-ignored: it lets an individual +//! developer apply more-opinionated overrides on top of the team-shared +//! `project` layer without committing them. +//! +//! Merge semantics: **tables merge recursively; scalars and arrays from a +//! higher-precedence layer overwrite wholesale.** A project file that sets +//! `tools.deny = [...]` replaces the array entirely — it does not append to +//! a user-level `tools.deny`. Tables (notably `providers`) accumulate: a +//! provider defined only at the base layer survives even when the project +//! layer adds a different provider. +//! +//! After merge, the document is resolved into a `Config`: +//! +//! - Every `providers.` becomes a `Provider`. Its API key is taken +//! from `api_key` if present, else from the environment variable named +//! by `api_key_env_var`. A provider whose only key source is an absent +//! env var is **silently dropped** — this is what lets the base layer +//! ship default OpenAI/Anthropic providers that simply don't appear when +//! the user hasn't exported the corresponding key. +//! - `defaults.model` is parsed as `:`. +//! - `tools` / `extensions` allow- and deny-lists are captured verbatim +//! (as glob pattern lists). A pattern appearing in both allow and deny +//! for the same kind is a hard error. +//! +//! Model *aliases* (the `` half of a model reference) are +//! resolved separately against `models.toml` — this module only validates +//! that the reference names a known provider. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const toml = @import("toml"); +const panto = @import("panto"); + +const glob = @import("glob.zig"); +const models_toml = @import("models_toml.zig"); + +// Document-building helpers live in the toml library's `value` module and +// are not re-exported at its root. `tableIterator` *is* re-exported, so we +// use `toml.tableIterator` directly but reach through `value_mod` for the +// constructors/mutators. +const tvalue = toml.value_mod; + +pub const APIStyle = panto.config.APIStyle; + +// =========================================================================== +// Resolved config model +// =========================================================================== + +/// One fully-resolved provider. The API key has already been read (from the +/// inline value or the named env var); a provider that never resolved a key +/// is absent from `Config.providers` entirely. +pub const Provider = struct { + /// The user-chosen provider name (the `providers.` key). This is + /// the name used on the left of a `:` reference. + name: []const u8, + style: APIStyle, + base_url: []const u8, + api_key: []const u8, + + pub fn deinit(self: Provider, alloc: Allocator) void { + alloc.free(self.name); + alloc.free(self.base_url); + alloc.free(self.api_key); + } +}; + +/// A parsed `:` reference. Both halves borrow from the +/// owning `Config` (the `default_model` storage); do not free separately. +pub const ModelRef = struct { + provider: []const u8, + model: []const u8, +}; + +/// Tool/extension availability policy. Empty `allow` means "allow all" +/// (subject to `deny`). A name is permitted when it matches at least one +/// allow pattern (or allow is empty) AND matches no deny pattern. +pub const Policy = struct { + allow: [][]const u8, + deny: [][]const u8, + + pub fn deinit(self: Policy, alloc: Allocator) void { + for (self.allow) |p| alloc.free(p); + alloc.free(self.allow); + for (self.deny) |p| alloc.free(p); + alloc.free(self.deny); + } + + /// True if `name` is permitted under this policy. + pub fn permits(self: Policy, name: []const u8) bool { + for (self.deny) |pat| { + if (glob.match(pat, name)) return false; + } + if (self.allow.len == 0) return true; + for (self.allow) |pat| { + if (glob.match(pat, name)) return true; + } + return false; + } +}; + +/// Build a `panto.config.ProviderConfig` for a chosen `:` model +/// reference, combining the resolved provider (transport/auth) with the +/// model definition from `models.toml` (wire name + knobs). +/// +/// `ref` selects the provider and alias. `defs` supplies per-model knobs; +/// a missing alias falls back to using the alias verbatim as the wire +/// model name with default knobs. Returns the assembled `Config` plus the +/// wire model id (borrowed from `defs`/`ref` — valid as long as both +/// outlive the returned config's use). The `panto.config.ProviderConfig` itself +/// borrows the provider/model strings; the caller must keep `cfg` (the +/// `Config`) and `defs` alive for its lifetime. +pub fn buildProviderConfig( + cfg: *const Config, + defs: *const models_toml.ModelRegistry, + ref: ModelRef, +) ResolveError!panto.config.ProviderConfig { + const prov = cfg.provider(ref.provider) orelse return error.UnknownProvider; + + const def_opt = defs.get(ref.provider, ref.model); + const wire_model: []const u8 = if (def_opt) |d| d.model else ref.model; + const reasoning: panto.config.ReasoningEffort = if (def_opt) |d| d.reasoning else .default; + + switch (prov.style) { + .openai_chat => return .{ .openai_chat = .{ + .api_key = prov.api_key, + .base_url = prov.base_url, + .model = wire_model, + .reasoning = reasoning, + } }, + .anthropic_messages => return .{ .anthropic_messages = .{ + .api_key = prov.api_key, + .base_url = prov.base_url, + .model = wire_model, + .api_version = if (def_opt) |d| (d.api_version orelse "2023-06-01") else "2023-06-01", + .max_tokens = if (def_opt) |d| (d.max_tokens orelse 4096) else 4096, + } }, + } +} + +pub const Config = struct { + allocator: Allocator, + providers: []Provider, + /// `defaults.model` storage, owned. `default_model_ref` slices into it. + default_model: ?[]const u8, + default_model_ref: ?ModelRef, + tools: Policy, + extensions: Policy, + + pub fn deinit(self: *Config) void { + for (self.providers) |p| p.deinit(self.allocator); + self.allocator.free(self.providers); + if (self.default_model) |m| self.allocator.free(m); + self.tools.deinit(self.allocator); + self.extensions.deinit(self.allocator); + } + + /// Look up a resolved provider by name. Returns null if absent (e.g. + /// dropped because its env var wasn't set). + pub fn provider(self: *const Config, name: []const u8) ?*const Provider { + for (self.providers) |*p| { + if (std.mem.eql(u8, p.name, name)) return p; + } + return null; + } + + /// Choose the model reference to start with. Precedence: + /// 1. an explicit `model_override` (e.g. a future `--model` flag), + /// 2. `defaults.model` from config, + /// 3. if exactly one provider resolved AND it has exactly one model + /// alias in `defs`, use that, + /// 4. otherwise error (`NoModelSelected`). + /// + /// The returned ref borrows from `self`/`defs`/`model_override`. + pub fn selectModel( + self: *const Config, + defs: *const models_toml.ModelRegistry, + model_override: ?[]const u8, + ) ResolveError!ModelRef { + if (model_override) |m| { + return parseModelRef(m) catch return error.NoModelSelected; + } + if (self.default_model_ref) |ref| return ref; + + // Single-provider, single-alias convenience. + if (self.providers.len == 1) { + const only = self.providers[0].name; + var found: ?ModelRef = null; + for (defs.entries.items) |d| { + if (std.mem.eql(u8, d.provider, only)) { + if (found != null) { + found = null; // more than one alias; ambiguous. + break; + } + found = .{ .provider = d.provider, .model = d.alias }; + } + } + if (found) |ref| return ref; + } + return error.NoModelSelected; + } +}; + +pub const Error = error{ + NoHomeDirectory, + InvalidConfigToml, + InvalidProvider, + InvalidModelRef, + UnknownDefaultProvider, + PolicyConflict, +} || Allocator.Error || FileError; + +pub const ResolveError = error{ + NoModelSelected, + UnknownProvider, +}; + +/// The filesystem errors that can surface while reading a config layer. +/// `FileNotFound` is handled by the caller (skip the layer); any other +/// I/O failure is collapsed to `error.ConfigReadFailed` so this module +/// keeps a small, stable error set. +pub const FileError = Io.Cancelable || error{ FileNotFound, ConfigReadFailed }; + +// =========================================================================== +// Public entry points +// =========================================================================== + +/// Resolve and merge the four config layers from their standard paths, +/// then build a `Config`. Missing files are skipped silently. `cwd` is the +/// project root (used for the `./.panto/config.toml` and +/// `./.panto/config.local.toml` layers). +pub fn load( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + cwd: []const u8, +) Error!Config { + const paths = try layerPaths(allocator, environ_map, cwd); + defer paths.deinit(allocator); + + return loadFromPaths(allocator, io, environ_map, &.{ + paths.base, + paths.user, + paths.project, + paths.local, + }); +} + +const LayerPaths = struct { + base: []u8, + user: []u8, + project: []u8, + local: []u8, + + fn deinit(self: LayerPaths, alloc: Allocator) void { + alloc.free(self.base); + alloc.free(self.user); + alloc.free(self.project); + alloc.free(self.local); + } +}; + +fn layerPaths( + allocator: Allocator, + environ_map: *const std.process.Environ.Map, + cwd: []const u8, +) Error!LayerPaths { + const base = try baseConfigPath(allocator, environ_map); + errdefer allocator.free(base); + const user = try userConfigPath(allocator, environ_map); + errdefer allocator.free(user); + const project = try std.fs.path.join(allocator, &.{ cwd, ".panto", "config.toml" }); + errdefer allocator.free(project); + const local = try std.fs.path.join(allocator, &.{ cwd, ".panto", "config.local.toml" }); + return .{ .base = base, .user = user, .project = project, .local = local }; +} + +/// `$PANTO_HOME/config.toml`, falling back to +/// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml`. +/// +/// `PANTO_HOME` is checked first so this always agrees with where the +/// bootstrap stages the default base config (see `panto_home.resolveHome`). +pub fn baseConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 { + if (environ_map.get("PANTO_HOME")) |home| { + return try std.fs.path.join(allocator, &.{ home, "config.toml" }); + } + if (environ_map.get("XDG_DATA_HOME")) |xdg| { + return try std.fs.path.join(allocator, &.{ xdg, "panto", "config.toml" }); + } + if (environ_map.get("HOME")) |home| { + return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "config.toml" }); + } + return error.NoHomeDirectory; +} + +/// `${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml`. +pub fn userConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 { + if (environ_map.get("XDG_CONFIG_HOME")) |xdg| { + return try std.fs.path.join(allocator, &.{ xdg, "panto", "config.toml" }); + } + if (environ_map.get("HOME")) |home| { + return try std.fs.path.join(allocator, &.{ home, ".config", "panto", "config.toml" }); + } + return error.NoHomeDirectory; +} + +/// Load from explicit layer paths, lowest precedence first. Useful for +/// tests. Missing files are skipped. +pub fn loadFromPaths( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + paths: []const []const u8, +) Error!Config { + // The merged document is built into its own arena-backed Document so we + // never have to reason about which source layer a given Value came from. + const merged = try tvalue.createDocument(allocator); + defer merged.deinit(); + + for (paths) |path| { + const bytes = readFileAlloc(allocator, io, path) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + defer allocator.free(bytes); + + const doc = parseDoc(allocator, bytes) catch return error.InvalidConfigToml; + defer doc.deinit(); + try mergeTable(merged.allocator(), merged.root, doc.root); + } + + return resolve(allocator, environ_map, merged.root); +} + +// =========================================================================== +// Merge +// =========================================================================== + +/// Deep-merge `src` table into `dst` table (both must be `.table`). +/// Tables recurse; everything else (scalars, arrays) overwrites. Values are +/// deep-copied into `alloc` (the merged document's arena) so they outlive +/// the source document. +fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void { + std.debug.assert(dst.* == .table); + if (src.* != .table) return; + + var it = toml.tableIterator(src); + while (it.next()) |entry| { + const existing = dst.get(entry.key); + if (existing != null and existing.?.* == .table and entry.value.* == .table) { + // Both sides are tables — recurse to accumulate keys. + try mergeTable(alloc, @constCast(existing.?), entry.value); + } else { + // Overwrite (or insert) with a deep copy of the source value. + const copy = try cloneValue(alloc, entry.value); + const key_copy = try alloc.dupe(u8, entry.key); + try tvalue.tableSet(alloc, dst, key_copy, copy); + } + } +} + +fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value { + const out = try alloc.create(toml.Value); + switch (src.*) { + .table => { + out.* = .{ .table = .{} }; + var it = toml.tableIterator(src); + while (it.next()) |entry| { + const child = try cloneValue(alloc, entry.value); + const key_copy = try alloc.dupe(u8, entry.key); + try tvalue.tableSet(alloc, out, key_copy, child); + } + }, + .array => |*a| { + out.* = .{ .array = .{} }; + for (a.items.items) |*item| { + const child = try cloneValue(alloc, item); + try tvalue.arrayAppend(alloc, out, child.*); + } + }, + .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) }, + else => out.* = src.*, + } + return out; +} + +// =========================================================================== +// Resolve +// =========================================================================== + +fn resolve( + allocator: Allocator, + environ_map: *const std.process.Environ.Map, + root: *const toml.Value, +) Error!Config { + var providers: std.ArrayList(Provider) = .empty; + errdefer { + for (providers.items) |p| p.deinit(allocator); + providers.deinit(allocator); + } + + if (root.get("providers")) |providers_tbl| { + if (providers_tbl.* == .table) { + var it = toml.tableIterator(providers_tbl); + while (it.next()) |entry| { + const maybe = try resolveProvider(allocator, environ_map, entry.key, entry.value); + if (maybe) |p| try providers.append(allocator, p); + } + } + } + + // Tool / extension policies. + var tools = try resolvePolicy(allocator, root, "tools"); + errdefer tools.deinit(allocator); + var extensions = try resolvePolicy(allocator, root, "extensions"); + errdefer extensions.deinit(allocator); + + // Default model. + var default_model: ?[]const u8 = null; + errdefer if (default_model) |m| allocator.free(m); + var default_model_ref: ?ModelRef = null; + if (root.get("defaults")) |defaults_tbl| { + if (defaults_tbl.get("model")) |model_v| { + if (model_v.* == .string) { + default_model = try allocator.dupe(u8, model_v.string); + default_model_ref = try parseModelRef(default_model.?); + } + } + } + + const providers_slice = try providers.toOwnedSlice(allocator); + errdefer { + for (providers_slice) |p| p.deinit(allocator); + allocator.free(providers_slice); + } + + // Validate the default model names a provider that actually resolved. + if (default_model_ref) |ref| { + var found = false; + for (providers_slice) |p| { + if (std.mem.eql(u8, p.name, ref.provider)) { + found = true; + break; + } + } + if (!found) return error.UnknownDefaultProvider; + } + + return .{ + .allocator = allocator, + .providers = providers_slice, + .default_model = default_model, + .default_model_ref = default_model_ref, + .tools = tools, + .extensions = extensions, + }; +} + +/// Resolve one `providers.` entry. Returns null (provider dropped) if +/// the only key source is an env var that isn't set. +fn resolveProvider( + allocator: Allocator, + environ_map: *const std.process.Environ.Map, + name: []const u8, + val: *const toml.Value, +) Error!?Provider { + if (val.* != .table) return error.InvalidProvider; + + const style_str = (val.get("style") orelse return error.InvalidProvider).asString() orelse + return error.InvalidProvider; + const style = std.meta.stringToEnum(APIStyle, style_str) orelse return error.InvalidProvider; + + const base_url = (val.get("base_url") orelse return error.InvalidProvider).asString() orelse + return error.InvalidProvider; + + // api_key wins over api_key_env_var. + const api_key: []const u8 = blk: { + if (val.get("api_key")) |k| { + if (k.asString()) |s| break :blk s; + } + if (val.get("api_key_env_var")) |ev| { + if (ev.asString()) |env_name| { + if (environ_map.get(env_name)) |actual| break :blk actual; + } + } + // No usable key — silently omit this provider. + return null; + }; + + return .{ + .name = try allocator.dupe(u8, name), + .style = style, + .base_url = try allocator.dupe(u8, base_url), + .api_key = try allocator.dupe(u8, api_key), + }; +} + +fn resolvePolicy(allocator: Allocator, root: *const toml.Value, key: []const u8) Error!Policy { + var allow: [][]const u8 = &.{}; + var deny: [][]const u8 = &.{}; + errdefer { + for (allow) |p| allocator.free(p); + allocator.free(allow); + for (deny) |p| allocator.free(p); + allocator.free(deny); + } + + if (root.get(key)) |tbl| { + if (tbl.* == .table) { + allow = try stringArray(allocator, tbl, "allow"); + deny = try stringArray(allocator, tbl, "deny"); + } + } + + // A pattern present in both allow and deny is contradictory. + for (allow) |a| { + for (deny) |d| { + if (std.mem.eql(u8, a, d)) return error.PolicyConflict; + } + } + + return .{ .allow = allow, .deny = deny }; +} + +fn stringArray(allocator: Allocator, tbl: *const toml.Value, key: []const u8) Error![][]const u8 { + const arr_v = tbl.get(key) orelse return &.{}; + if (arr_v.* != .array) return &.{}; + + var list: std.ArrayList([]const u8) = .empty; + errdefer { + for (list.items) |s| allocator.free(s); + list.deinit(allocator); + } + for (arr_v.array.items.items) |*item| { + if (item.* == .string) { + try list.append(allocator, try allocator.dupe(u8, item.string)); + } + } + return try list.toOwnedSlice(allocator); +} + +/// Parse `:`. Both halves must be non-empty and there must +/// be exactly one separating colon. +pub fn parseModelRef(ref: []const u8) Error!ModelRef { + const colon = std.mem.indexOfScalar(u8, ref, ':') orelse return error.InvalidModelRef; + const provider = ref[0..colon]; + const model = ref[colon + 1 ..]; + if (provider.len == 0 or model.len == 0) return error.InvalidModelRef; + if (std.mem.indexOfScalar(u8, model, ':') != null) return error.InvalidModelRef; + return .{ .provider = provider, .model = model }; +} + +// =========================================================================== +// File / parse helpers +// =========================================================================== + +fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) (Allocator.Error || FileError)![]u8 { + const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { + error.FileNotFound => return error.FileNotFound, + error.Canceled => return error.Canceled, + else => return error.ConfigReadFailed, + }; + defer file.close(io); + const len = file.length(io) catch return error.ConfigReadFailed; + const bytes = try allocator.alloc(u8, @intCast(len)); + errdefer allocator.free(bytes); + _ = file.readPositionalAll(io, bytes, 0) catch |err| switch (err) { + error.Canceled => return error.Canceled, + else => return error.ConfigReadFailed, + }; + return bytes; +} + +fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document { + const result = toml.parseWithError(allocator, source, .{}); + switch (result) { + .err => |e| { + if (!@import("builtin").is_test) { + std.log.err( + "config.toml: parse error at line {d}, column {d}: {s}", + .{ e.line, e.column, e.message }, + ); + } + return error.InvalidConfigToml; + }, + .ok => |doc| return doc, + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; + +fn emptyEnv(a: Allocator) std.process.Environ.Map { + return std.process.Environ.Map.init(a); +} + +test "parseModelRef: splits provider and model" { + const ref = try parseModelRef("anthropic:claude-sonnet-4-6"); + try testing.expectEqualStrings("anthropic", ref.provider); + try testing.expectEqualStrings("claude-sonnet-4-6", ref.model); +} + +test "parseModelRef: rejects malformed refs" { + try testing.expectError(error.InvalidModelRef, parseModelRef("no-colon")); + try testing.expectError(error.InvalidModelRef, parseModelRef(":model")); + try testing.expectError(error.InvalidModelRef, parseModelRef("provider:")); + try testing.expectError(error.InvalidModelRef, parseModelRef("a:b:c")); +} + +test "Policy.permits: empty allow means allow-all minus deny" { + const a = testing.allocator; + var deny = try a.alloc([]const u8, 1); + deny[0] = try a.dupe(u8, "std.shell"); + const policy: Policy = .{ .allow = &.{}, .deny = deny }; + defer policy.deinit(a); + + try testing.expect(policy.permits("std.read")); + try testing.expect(!policy.permits("std.shell")); +} + +test "Policy.permits: allow gates everything not matched" { + const a = testing.allocator; + var allow = try a.alloc([]const u8, 1); + allow[0] = try a.dupe(u8, "std.*"); + const policy: Policy = .{ .allow = allow, .deny = &.{} }; + defer policy.deinit(a); + + try testing.expect(policy.permits("std.read")); + try testing.expect(!policy.permits("other.read")); +} + +test "resolve: env-backed provider is dropped when env var is absent" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + + const src = + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key_env_var = "ANTHROPIC_API_KEY" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + try testing.expectEqual(@as(usize, 0), cfg.providers.len); +} + +test "resolve: env-backed provider resolves when env var is set" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz"); + + const src = + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key_env_var = "ANTHROPIC_API_KEY" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + try testing.expectEqual(@as(usize, 1), cfg.providers.len); + const p = cfg.provider("anthropic").?; + try testing.expectEqual(APIStyle.anthropic_messages, p.style); + try testing.expectEqualStrings("sk-ant-xyz", p.api_key); +} + +test "resolve: inline api_key wins over env var" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("KEY_FROM_ENV", "env-value"); + + const src = + \\[providers.openai] + \\style = "openai_chat" + \\base_url = "https://api.openai.com/v1" + \\api_key = "inline-value" + \\api_key_env_var = "KEY_FROM_ENV" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + const p = cfg.provider("openai").?; + try testing.expectEqualStrings("inline-value", p.api_key); +} + +test "resolve: unknown default-model provider is an error" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + + const src = + \\[defaults] + \\model = "ghost:some-model" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + + try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root)); +} + +test "resolvePolicy: pattern in both allow and deny is a conflict" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + + const src = + \\[tools] + \\allow = ["std.*"] + \\deny = ["std.*"] + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + + try testing.expectError(error.PolicyConflict, resolve(a, &env, doc.root)); +} + +test "loadFromPaths: layers merge with tables accumulating and scalars overriding" { + 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]; + + // Base layer: defines anthropic provider + a default model. + const sys_path = try std.fs.path.join(a, &.{ tmp_path, "base.toml" }); + defer a.free(sys_path); + try tmp.dir.writeFile(io, .{ .sub_path = "base.toml", .data = + \\[defaults] + \\model = "anthropic:sonnet" + \\ + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key = "sys-key" + \\ + \\[tools] + \\allow = ["std.*"] + }); + + // Project layer: adds an openai provider (table accumulates), and + // overrides the default model (scalar overwrites). + const proj_path = try std.fs.path.join(a, &.{ tmp_path, "project.toml" }); + defer a.free(proj_path); + try tmp.dir.writeFile(io, .{ .sub_path = "project.toml", .data = + \\[defaults] + \\model = "openai:gpt" + \\ + \\[providers.openai] + \\style = "openai_chat" + \\base_url = "https://api.openai.com/v1" + \\api_key = "proj-key" + }); + + var env = emptyEnv(a); + defer env.deinit(); + + var cfg = try loadFromPaths(a, io, &env, &.{ sys_path, proj_path }); + defer cfg.deinit(); + + // Both providers survive (table accumulation). + try testing.expectEqual(@as(usize, 2), cfg.providers.len); + try testing.expect(cfg.provider("anthropic") != null); + try testing.expect(cfg.provider("openai") != null); + + // Default model overridden by the project layer. + try testing.expectEqualStrings("openai:gpt", cfg.default_model.?); + try testing.expectEqualStrings("openai", cfg.default_model_ref.?.provider); + try testing.expectEqualStrings("gpt", cfg.default_model_ref.?.model); + + // tools.allow from the base layer is preserved (no project override). + try testing.expect(cfg.tools.permits("std.read")); +} + +test "loadFromPaths: local layer overrides project layer" { + 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]; + + // Project layer: shared default model + provider. + const proj_path = try std.fs.path.join(a, &.{ tmp_path, "project.toml" }); + defer a.free(proj_path); + try tmp.dir.writeFile(io, .{ .sub_path = "project.toml", .data = + \\[defaults] + \\model = "openai:gpt" + \\ + \\[providers.openai] + \\style = "openai_chat" + \\base_url = "https://api.openai.com/v1" + \\api_key = "proj-key" + }); + + // Local layer (git-ignored): a developer's more-opinionated override of + // the default model. Scalar overwrites; the provider table accumulates. + const local_path = try std.fs.path.join(a, &.{ tmp_path, "local.toml" }); + defer a.free(local_path); + try tmp.dir.writeFile(io, .{ .sub_path = "local.toml", .data = + \\[defaults] + \\model = "openai:gpt-local" + }); + + var env = emptyEnv(a); + defer env.deinit(); + + var cfg = try loadFromPaths(a, io, &env, &.{ proj_path, local_path }); + defer cfg.deinit(); + + // Local layer wins for the default model. + try testing.expectEqualStrings("openai:gpt-local", cfg.default_model.?); + // Provider from the project layer survives (table accumulation). + try testing.expect(cfg.provider("openai") != null); +} + +test "buildProviderConfig: anthropic ref pulls knobs from model defs" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("ANTHROPIC_API_KEY", "sk-ant"); + + const src = + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key_env_var = "ANTHROPIC_API_KEY" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + + var models = models_toml.ModelRegistry.init(a); + defer models.deinit(); + try models.entries.append(a, .{ + .provider = try a.dupe(u8, "anthropic"), + .alias = try a.dupe(u8, "sonnet"), + .model = try a.dupe(u8, "claude-sonnet-4-20250514"), + .reasoning = .high, + .max_tokens = 8192, + .api_version = try a.dupe(u8, "2023-06-01"), + }); + + const ref = try parseModelRef("anthropic:sonnet"); + const pc = try buildProviderConfig(&cfg, &models, ref); + try testing.expectEqual(APIStyle.anthropic_messages, pc.style()); + try testing.expectEqualStrings("claude-sonnet-4-20250514", pc.anthropic_messages.model); + try testing.expectEqual(@as(u32, 8192), pc.anthropic_messages.max_tokens); + try testing.expectEqualStrings("sk-ant", pc.anthropic_messages.api_key); +} + +test "buildProviderConfig: missing alias uses the alias verbatim as wire model" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + + const src = + \\[providers.openai] + \\style = "openai_chat" + \\base_url = "https://api.openai.com/v1" + \\api_key = "sk-test" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + + var models = models_toml.ModelRegistry.init(a); + defer models.deinit(); + + const ref = try parseModelRef("openai:gpt-4o"); + const pc = try buildProviderConfig(&cfg, &models, ref); + try testing.expectEqual(APIStyle.openai_chat, pc.style()); + try testing.expectEqualStrings("gpt-4o", pc.openai_chat.model); + try testing.expectEqual(panto.config.ReasoningEffort.default, pc.openai_chat.reasoning); +} + +test "buildProviderConfig: unknown provider errors" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + const empty = try parseDoc(a, ""); + defer empty.deinit(); + var cfg = try resolve(a, &env, empty.root); + defer cfg.deinit(); + var models = models_toml.ModelRegistry.init(a); + defer models.deinit(); + try testing.expectError(error.UnknownProvider, buildProviderConfig(&cfg, &models, .{ .provider = "ghost", .model = "x" })); +} + +test "selectModel: default_model wins; single-provider fallback otherwise" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + + // Config with one provider and a default model. + const src = + \\[defaults] + \\model = "openai:gpt" + \\ + \\[providers.openai] + \\style = "openai_chat" + \\base_url = "https://api.openai.com/v1" + \\api_key = "sk" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + + var models = models_toml.ModelRegistry.init(a); + defer models.deinit(); + + const ref = try cfg.selectModel(&models, null); + try testing.expectEqualStrings("openai", ref.provider); + try testing.expectEqualStrings("gpt", ref.model); + + // Override beats default. + const ref2 = try cfg.selectModel(&models, "openai:other"); + try testing.expectEqualStrings("other", ref2.model); +} + +test "selectModel: no default and no providers errors" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + const empty = try parseDoc(a, ""); + defer empty.deinit(); + var cfg = try resolve(a, &env, empty.root); + defer cfg.deinit(); + var models = models_toml.ModelRegistry.init(a); + defer models.deinit(); + try testing.expectError(error.NoModelSelected, cfg.selectModel(&models, null)); +} + +test "loadFromPaths: missing files are skipped" { + const a = testing.allocator; + const io = testing.io; + var env = emptyEnv(a); + defer env.deinit(); + + var cfg = try loadFromPaths(a, io, &env, &.{ + "/nonexistent/base.toml", + "/nonexistent/project.toml", + }); + defer cfg.deinit(); + try testing.expectEqual(@as(usize, 0), cfg.providers.len); + try testing.expect(cfg.default_model == null); +} -- cgit v1.3