//! 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.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.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.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.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.ReasoningEffort = if (def_opt) |d| d.reasoning else .default; const max_tokens: u32 = if (def_opt) |d| (d.max_tokens orelse 64_000) else 64_000; switch (prov.style) { .openai_chat => return .{ .openai_chat = .{ .api_key = prov.api_key, .base_url = prov.base_url, .model = wire_model, .reasoning = reasoning, .max_tokens = max_tokens, } }, .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 = max_tokens, .thinking = if (def_opt) |d| d.thinking else .disabled, .effort = if (def_opt) |d| d.effort else .medium, .thinking_budget_tokens = if (def_opt) |d| d.thinking_budget_tokens else 32_000, .thinking_interleaved = if (def_opt) |d| d.thinking_interleaved else false, } }, } } 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, /// `[compaction]` settings. `compaction_keep_verbatim` is the kept- /// suffix token budget (null = use libpanto's default). `compaction_model` /// is an owned `:` reference string for the optional /// compaction-model override; `compaction_model_ref` slices into it. compaction_keep_verbatim: ?u32 = null, compaction_model: ?[]const u8 = null, compaction_model_ref: ?ModelRef = null, 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); if (self.compaction_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.?); } } } // `[compaction]` settings. var compaction_keep_verbatim: ?u32 = null; var compaction_model: ?[]const u8 = null; errdefer if (compaction_model) |m| allocator.free(m); var compaction_model_ref: ?ModelRef = null; if (root.get("compaction")) |compaction_tbl| { if (compaction_tbl.get("keep_verbatim")) |kv| { if (kv.* == .integer and kv.integer > 0) { compaction_keep_verbatim = @intCast(kv.integer); } } if (compaction_tbl.get("model")) |model_v| { if (model_v.* == .string) { compaction_model = try allocator.dupe(u8, model_v.string); compaction_model_ref = try parseModelRef(compaction_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; } // Validate the compaction-model override names a provider that resolved. if (compaction_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, .compaction_keep_verbatim = compaction_keep_verbatim, .compaction_model = compaction_model, .compaction_model_ref = compaction_model_ref, }; } /// 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"), .thinking = .enabled, .effort = .medium, .thinking_budget_tokens = 16_000, .thinking_interleaved = false, }); 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); try testing.expectEqual(panto.Thinking.enabled, pc.anthropic_messages.thinking); try testing.expectEqual(panto.Effort.medium, pc.anthropic_messages.effort); try testing.expectEqual(@as(?u32, 16_000), pc.anthropic_messages.thinking_budget_tokens); try testing.expectEqual(false, pc.anthropic_messages.thinking_interleaved); } 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.ReasoningEffort.default, pc.openai_chat.reasoning); } test "buildProviderConfig: anthropic adaptive thinking threaded from model def" { 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, "opus"), .model = try a.dupe(u8, "claude-opus-4-8"), .reasoning = .default, .max_tokens = null, .api_version = null, .thinking = .adaptive, .effort = .high, .thinking_budget_tokens = null, .thinking_interleaved = false, }); const ref = try parseModelRef("anthropic:opus"); const pc = try buildProviderConfig(&cfg, &models, ref); try testing.expectEqual(panto.Thinking.adaptive, pc.anthropic_messages.thinking); try testing.expectEqual(panto.Effort.high, pc.anthropic_messages.effort); try testing.expectEqual(@as(?u32, null), pc.anthropic_messages.thinking_budget_tokens); } test "buildProviderConfig: anthropic missing alias falls back to struct defaults" { 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(); // No model def at all — alias used verbatim, defaults applied. const ref = try parseModelRef("anthropic:claude-haiku-4-5-20251001"); const pc = try buildProviderConfig(&cfg, &models, ref); try testing.expectEqual(APIStyle.anthropic_messages, pc.style()); try testing.expectEqual(panto.Thinking.disabled, pc.anthropic_messages.thinking); try testing.expectEqual(panto.Effort.medium, pc.anthropic_messages.effort); try testing.expectEqual(@as(?u32, 32_000), pc.anthropic_messages.thinking_budget_tokens); try testing.expectEqual(false, pc.anthropic_messages.thinking_interleaved); } 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); } test "resolve: [compaction] keep_verbatim and model parse" { 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" \\ \\[compaction] \\keep_verbatim = 12345 \\model = "anthropic:haiku" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolve(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqual(@as(?u32, 12345), cfg.compaction_keep_verbatim); try testing.expectEqualStrings("anthropic:haiku", cfg.compaction_model.?); try testing.expectEqualStrings("anthropic", cfg.compaction_model_ref.?.provider); try testing.expectEqualStrings("haiku", cfg.compaction_model_ref.?.model); } test "resolve: [compaction] absent leaves defaults null" { 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.expect(cfg.compaction_keep_verbatim == null); try testing.expect(cfg.compaction_model == null); try testing.expect(cfg.compaction_model_ref == null); } test "resolve: [compaction] model naming an unresolved provider errors" { 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" \\ \\[compaction] \\model = "openai:gpt-4o" ; const doc = try parseDoc(a, src); defer doc.deinit(); try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root)); }