//! 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.** Tables (notably `providers`) //! accumulate: a provider defined only at the base layer survives even when //! the project layer adds a different provider. //! //! **Exception — `[extensions]` allow/deny.** These are NOT clobbered across //! layers. Every layer's rules are retained, tagged with their layer, and //! resolved by "last matching rule wins" after sorting by //! `(layer, specificity, deny-last)` — see `Policy`. So a higher layer's rule //! beats a lower layer's, within a layer the more-specific pattern wins, and //! `deny` wins an exact tie. This lets a base layer carve one name out of an //! otherwise-denied group without a higher layer's unrelated rule erasing it. //! //! After merge, the document is resolved into a `Config`: //! //! - Every `auth.` becomes a `ResolvedAuth` — a named auth session //! (`api_key` or `oauth_device`; `type` is inferred from the keys present //! when omitted). All values support `${...}` substitution: `${env:VAR}` //! reads the environment, `${sibling}` reads another key in the same //! section. For `api_key`, `key` is resolved eagerly (usually //! `"${env:VAR}"`); an empty result leaves the session **unresolved**, and //! any provider using it is dropped. OAuth sessions carry their flow config //! and are resolved at turn time. //! - Every `providers.` becomes a `Provider`. A networked provider //! **must** name its auth session via `auth = ""` (clean break: //! provider-level `api_key`/`api_key_env_var` are no longer accepted). //! A provider survives config resolution even when its auth is not //! currently resolved. An `anthropic_messages` provider may also set //! `prompt_cache = ` (default true) to control the advancing //! `cache_control` breakpoint; it is ignored for `openai_chat` //! providers. `model_catalog_name = ` optionally maps the //! provider to an external model catalog key (sync tooling otherwise //! falls back to the provider name). `extra_headers` (a table of string //! headers) rides on the model request. //! - `defaults.model` is parsed as `:`. //! - `[extensions]` allow/deny globs across all layers become one sorted //! `Policy` rule list (see the merge-semantics note above). A pattern in //! both allow and deny is no longer an error — resolution is deterministic //! (deny wins the tie). //! //! 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 tvalue = toml.value_mod; const glob = @import("glob.zig"); const models_toml = @import("models_toml.zig"); const panto_home = @import("panto_home.zig"); const toml_layer = @import("toml_layer.zig"); pub const APIStyle = panto.APIStyle; // =========================================================================== // Resolved config model // =========================================================================== /// One resolved provider. Transport-only: it names the auth session that /// supplies its credential (`auth_name`); the credential itself is resolved /// separately (eagerly for `api_key`, at turn time for OAuth). A provider /// always survives config resolution, even when its auth is unresolved. 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, /// User-facing sub-dialect for styles that share a protocol family. /// Currently only `style = "openai_responses"` supports `dialect = "codex"`, /// which maps to internal `APIStyle.openai_codex_responses`. dialect: ?[]const u8 = null, base_url: []const u8, /// The `auth.` session this provider draws its credential from. auth_name: []const u8, /// Optional external model-catalog provider key (e.g. `github-copilot` /// on models.dev). Absent => sync tooling falls back to `name`. model_catalog_name: ?[]const u8 = null, /// anthropic_messages only. Place one advancing `cache_control` /// breakpoint on each request. Defaults to the library default (true); /// ignored for `openai_chat` providers. prompt_cache: bool = true, /// Static request headers merged onto the model request. Borrowed from /// the owning `Config`'s auth arena (freed with the Config, not here). extra_headers: []const panto.Header = &.{}, pub fn deinit(self: Provider, alloc: Allocator) void { alloc.free(self.name); if (self.dialect) |d| alloc.free(d); alloc.free(self.base_url); alloc.free(self.auth_name); if (self.model_catalog_name) |m| alloc.free(m); // `extra_headers` lives in the Config auth arena; not freed here. } }; /// Look up a resolved auth session by name in a slice. Returns null if absent. fn authByName(auths: []const ResolvedAuth, name: []const u8) ?*const ResolvedAuth { for (auths) |*a| { if (std.mem.eql(u8, a.name, name)) return a; } return null; } /// One resolved named auth session (`auth.`). The `config` carries the /// auth-type-specific settings (borrowed from the Config auth arena). /// `resolved_api_key` is set eagerly for `api_key` sessions whose key is /// available now (literal or present env var); it is null for OAuth sessions /// (resolved at turn time) and for `api_key` sessions whose env var is absent. pub const ResolvedAuth = struct { name: []const u8, config: panto.AuthConfig, resolved_api_key: ?[]const u8 = null, }; /// 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, }; pub const Verdict = enum { allow, deny }; /// One allow/deny glob rule, tagged with the config layer it came from and /// its precomputed specificity. Rules are kept from every layer (never /// clobbered) and resolved by "last matching rule wins" after sorting. pub const Rule = struct { pattern: []const u8, verdict: Verdict, layer: u16, spec: glob.Specificity, }; /// Extension availability policy: an ordered list of allow/deny glob rules /// drawn from every config layer. The default is **allow** (an empty policy /// permits everything). A name's verdict is the LAST rule that matches it, /// where rules are sorted ascending by `(layer, specificity, allow-before-deny)`. /// So a higher layer's rule beats a lower layer's; within a layer the more /// specific pattern wins; at an exact tie `deny` wins. A pure whitelist is /// `deny = ["**"]` plus specific `allow` carve-outs. pub const Policy = struct { rules: []Rule = &.{}, pub fn deinit(self: Policy, alloc: Allocator) void { for (self.rules) |r| alloc.free(r.pattern); alloc.free(self.rules); } /// True if `name` is permitted. Scans the sorted rule list and keeps the /// verdict of the last matching rule; defaults to allow when nothing /// matches. pub fn permits(self: Policy, name: []const u8) bool { var verdict: Verdict = .allow; for (self.rules) |r| { if (glob.match(r.pattern, name)) verdict = r.verdict; } return verdict == .allow; } }; /// Ascending sort: `(layer, specificity, allow-before-deny)`. After sorting, /// the last rule that matches a name is the winner. fn ruleLessThan(_: void, a: Rule, b: Rule) bool { if (a.layer != b.layer) return a.layer < b.layer; switch (a.spec.order(b.spec)) { .lt => return true, .gt => return false, .eq => {}, } // allow (0) sorts before deny (1) so deny wins an exact tie. return @intFromEnum(a.verdict) < @intFromEnum(b.verdict); } /// Append the `[extensions]` allow/deny rules from one layer's document to /// `out`, tagged with `layer`. Duplicating patterns is harmless — resolution /// is deterministic (deny wins ties). fn collectRules( allocator: Allocator, root: *const toml.Value, layer: u16, out: *std.ArrayList(Rule), ) Error!void { const tbl = root.get("extensions") orelse return; if (tbl.* != .table) return; inline for (.{ .{ "allow", Verdict.allow }, .{ "deny", Verdict.deny } }) |pair| { const patterns = try stringArray(allocator, tbl, pair[0]); defer allocator.free(patterns); // the slice; strings move into `out` var i: usize = 0; // On error, free only the patterns not yet moved into `out`; the // moved ones (patterns[0..i]) are owned by `out` and freed by the caller. errdefer for (patterns[i..]) |p| allocator.free(p); while (i < patterns.len) : (i += 1) { try out.append(allocator, .{ .pattern = patterns[i], .verdict = pair[1], .layer = layer, .spec = glob.specificity(patterns[i]), }); } } } /// Sort collected rules into resolution order and wrap in a `Policy`. fn buildPolicy(allocator: Allocator, rules: *std.ArrayList(Rule)) Policy { std.mem.sort(Rule, rules.items, {}, ruleLessThan); return .{ .rules = rules.toOwnedSlice(allocator) catch &.{} }; } /// A config string (a `paths` entry or a `rocks` spec) tagged with the layer /// it came from, so the loader can assign it the right precedence. pub const LayeredStr = struct { value: []const u8, layer: u16, }; /// Append the string array `extensions.` from one layer to `out`, /// tagged with `layer`. Used for `paths` and `rocks` (source lists that /// accumulate across layers rather than clobbering). fn collectStrings( allocator: Allocator, root: *const toml.Value, key: []const u8, layer: u16, out: *std.ArrayList(LayeredStr), ) Error!void { const tbl = root.get("extensions") orelse return; if (tbl.* != .table) return; const values = try stringArray(allocator, tbl, key); defer allocator.free(values); var i: usize = 0; errdefer for (values[i..]) |v| allocator.free(v); while (i < values.len) : (i += 1) { try out.append(allocator, .{ .value = values[i], .layer = layer }); } } fn freeLayeredStrs(allocator: Allocator, list: []LayeredStr) void { for (list) |s| allocator.free(s.value); allocator.free(list); } /// 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 auth_cfg = cfg.auth(prov.auth_name) orelse unreachable; // The provider's credential comes from its named auth session. For // `api_key` sessions this is resolved eagerly at load; for OAuth it is // null here and patched in at turn time by the embedder's auth manager // (which also supplies a dynamic base_url and auth-derived headers). An // empty string is a deliberate placeholder: an unresolved session yields // a clear auth error on its first request rather than a silent drop. const api_key: []const u8 = auth_cfg.resolved_api_key orelse ""; const anthropic_use_bearer_auth = auth_cfg.config == .oauth_device; 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 = api_key, .base_url = prov.base_url, .model = wire_model, .reasoning = reasoning, .max_tokens = max_tokens, .extra_headers = prov.extra_headers, } }, .anthropic_messages => return .{ .anthropic_messages = .{ .api_key = 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, .prompt_cache = prov.prompt_cache, .use_bearer_auth = anthropic_use_bearer_auth, .extra_headers = prov.extra_headers, } }, .openai_responses => return .{ .openai_responses = .{ .api_key = api_key, .base_url = prov.base_url, .model = wire_model, .reasoning = reasoning, .max_tokens = max_tokens, .extra_headers = prov.extra_headers, } }, .openai_codex_responses => return .{ .openai_codex_responses = .{ .api_key = api_key, .base_url = prov.base_url, .model = wire_model, .reasoning = reasoning, .max_tokens = max_tokens, .extra_headers = prov.extra_headers, } }, } } pub const Config = struct { allocator: Allocator, providers: []Provider, /// Named auth sessions (`auth.`). All auth-config strings, header /// arrays, and provider `extra_headers` are owned by `auth_arena`. auths: []ResolvedAuth, /// Arena owning every auth-related allocation (auth names, `AuthConfig` /// string fields and header arrays, resolved api keys, and provider /// `extra_headers`). Freed wholesale in `deinit`. auth_arena: std.heap.ArenaAllocator, /// `defaults.model` storage, owned. `default_model_ref` slices into it. default_model: ?[]const u8, default_model_ref: ?ModelRef, /// Extension availability policy (the `[extensions]` section), resolved /// across all config layers. Governs every lua-authored capability. extensions: Policy, /// Extra filesystem dirs to scan for extensions (`extensions.paths`), /// each tagged with the config layer that listed it. ext_paths: []LayeredStr = &.{}, /// luarocks dependency strings to load as extension sources /// (`extensions.rocks`), each tagged with its config layer. ext_rocks: []LayeredStr = &.{}, /// `[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); self.allocator.free(self.auths); if (self.default_model) |m| self.allocator.free(m); if (self.compaction_model) |m| self.allocator.free(m); self.extensions.deinit(self.allocator); freeLayeredStrs(self.allocator, self.ext_paths); freeLayeredStrs(self.allocator, self.ext_rocks); // Frees every auth-config string, header array, resolved api key, // and provider `extra_headers` in one shot. self.auth_arena.deinit(); } /// Look up a resolved provider by name. Returns null if absent. 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; } /// Look up a named auth session. Returns null if absent. pub fn auth(self: *const Config, name: []const u8) ?*const ResolvedAuth { for (self.auths) |*a| { if (std.mem.eql(u8, a.name, name)) return a; } 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, /// An `[auth.]` block is malformed (unknown/uninferable type, or a /// required field for its type is absent or substitutes to empty). InvalidAuth, /// A networked provider is missing `auth = ""`. MissingProviderAuth, /// A provider's `auth = ""` names a session with no `[auth.]`. UnknownProviderAuth, } || 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 }; } /// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml`. pub fn baseConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 { const home = try panto_home.homePath(allocator, environ_map); defer allocator.free(home); return try std.fs.path.join(allocator, &.{ home, "config.toml" }); } /// `${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; } /// Resolve a `Config` from a single in-memory TOML string. Convenience for /// tests and tooling that already hold the document text. pub fn loadFromString( allocator: Allocator, environ_map: *const std.process.Environ.Map, source: []const u8, ) Error!Config { const doc = parseDoc(allocator, source) catch return error.InvalidConfigToml; defer doc.deinit(); var srcs = try SourceLists.collect(allocator, &.{doc.root}); errdefer srcs.deinit(allocator); const policy = buildPolicy(allocator, &srcs.rules); return resolve( allocator, environ_map, doc.root, policy, try srcs.paths.toOwnedSlice(allocator), try srcs.rocks.toOwnedSlice(allocator), ); } /// Accumulates the per-layer `[extensions]` rules and source lists during a /// layered load. `collect` gathers from a slice of already-parsed layer /// roots (lowest precedence first). const SourceLists = struct { rules: std.ArrayList(Rule) = .empty, paths: std.ArrayList(LayeredStr) = .empty, rocks: std.ArrayList(LayeredStr) = .empty, fn collect(allocator: Allocator, roots: []const *const toml.Value) Error!SourceLists { var self: SourceLists = .{}; errdefer self.deinit(allocator); for (roots, 0..) |root, i| { const layer: u16 = @intCast(i); try collectRules(allocator, root, layer, &self.rules); try collectStrings(allocator, root, "paths", layer, &self.paths); try collectStrings(allocator, root, "rocks", layer, &self.rocks); } return self; } fn deinit(self: *SourceLists, allocator: Allocator) void { for (self.rules.items) |r| allocator.free(r.pattern); self.rules.deinit(allocator); for (self.paths.items) |s| allocator.free(s.value); self.paths.deinit(allocator); for (self.rocks.items) |s| allocator.free(s.value); self.rocks.deinit(allocator); } }; /// 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 — // EXCEPT the `[extensions]` allow/deny rules, which must retain their // layer of origin (they are collected separately, not merged/clobbered). const merged = try tvalue.createDocument(allocator); defer merged.deinit(); var srcs: SourceLists = .{}; errdefer srcs.deinit(allocator); for (paths, 0..) |path, i| { const bytes = toml_layer.readFileAlloc(allocator, io, path) catch |err| switch (err) { error.FileNotFound => continue, error.ReadFailed => return error.ConfigReadFailed, error.Canceled => return error.Canceled, error.OutOfMemory => return error.OutOfMemory, }; defer allocator.free(bytes); const doc = parseDoc(allocator, bytes) catch return error.InvalidConfigToml; defer doc.deinit(); // Capture this layer's policy rules + source lists before the // clobbering merge (they accumulate across layers, not clobber). const layer: u16 = @intCast(i); try collectRules(allocator, doc.root, layer, &srcs.rules); try collectStrings(allocator, doc.root, "paths", layer, &srcs.paths); try collectStrings(allocator, doc.root, "rocks", layer, &srcs.rocks); try toml_layer.mergeTable(merged.allocator(), merged.root, doc.root); } const policy = buildPolicy(allocator, &srcs.rules); return resolve( allocator, environ_map, merged.root, policy, try srcs.paths.toOwnedSlice(allocator), try srcs.rocks.toOwnedSlice(allocator), ); } // =========================================================================== // Resolve // =========================================================================== fn resolve( allocator: Allocator, environ_map: *const std.process.Environ.Map, root: *const toml.Value, /// Extension policy + source lists, pre-built from all layers by the /// caller. Ownership transfers into the returned Config (or is freed on /// the error path). extensions: Policy, ext_paths: []LayeredStr, ext_rocks: []LayeredStr, ) Error!Config { errdefer extensions.deinit(allocator); errdefer freeLayeredStrs(allocator, ext_paths); errdefer freeLayeredStrs(allocator, ext_rocks); // Arena owning every auth-related allocation. Moved into the returned // Config on success; deinit'd on any error path before then. var auth_arena = std.heap.ArenaAllocator.init(allocator); errdefer auth_arena.deinit(); const aa = auth_arena.allocator(); // Named auth sessions (`auth.`), parsed first so providers can be // validated against them. var auths: std.ArrayList(ResolvedAuth) = .empty; errdefer auths.deinit(allocator); if (root.get("auth")) |auth_tbl| { if (auth_tbl.* == .table) { var it = toml.tableIterator(auth_tbl); while (it.next()) |entry| { const ra = try resolveAuth(aa, environ_map, entry.key, entry.value); try auths.append(allocator, ra); } } } 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 p = try resolveProvider(allocator, aa, entry.key, entry.value); try providers.append(allocator, p); } } } // Every provider must name an auth session that exists. for (providers.items) |p| { if (authByName(auths.items, p.auth_name) == null) return error.UnknownProviderAuth; } // Drop any provider whose `api_key` session did not resolve (an absent // `${env:VAR}` reads as empty) — reproducing the "export your key or the // provider disappears" behavior for the default OpenAI/Anthropic entries. // OAuth providers always survive (resolved at turn time / via login). This // is an in-place, allocation-free compaction so a mid-loop failure can't // leave two slices owning the same strings. { var w: usize = 0; for (providers.items) |p| { const a = authByName(auths.items, p.auth_name).?; if (a.config == .api_key and a.resolved_api_key == null) { p.deinit(allocator); } else { providers.items[w] = p; w += 1; } } providers.shrinkRetainingCapacity(w); } // 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); } const auths_slice = try auths.toOwnedSlice(allocator); errdefer allocator.free(auths_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, .auths = auths_slice, .auth_arena = auth_arena, .default_model = default_model, .default_model_ref = default_model_ref, .extensions = extensions, .ext_paths = ext_paths, .ext_rocks = ext_rocks, .compaction_keep_verbatim = compaction_keep_verbatim, .compaction_model = compaction_model, .compaction_model_ref = compaction_model_ref, }; } /// Resolve one `providers.` entry. A provider always survives (no /// silent drop): it names the auth session that supplies its credential, and /// an unresolved session simply fails on first request. `name`/`base_url`/ /// `auth_name` are duped with `allocator`; `extra_headers` is allocated into /// `aa` (the Config auth arena). fn resolveProvider( allocator: Allocator, aa: Allocator, 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; var style = std.meta.stringToEnum(APIStyle, style_str) orelse return error.InvalidProvider; if (style == .openai_codex_responses) return error.InvalidProvider; const dialect = if (tomlStr(val, "dialect")) |d| d else null; if (dialect) |d| { if (std.mem.eql(u8, style_str, "openai_responses") and std.mem.eql(u8, d, "codex")) { style = .openai_codex_responses; } else { return error.InvalidProvider; } } const base_url = (val.get("base_url") orelse return error.InvalidProvider).asString() orelse return error.InvalidProvider; // Clean break: a networked provider must name its auth session. const auth_name = (val.get("auth") orelse return error.MissingProviderAuth).asString() orelse return error.MissingProviderAuth; const model_catalog_name = tomlStr(val, "model_catalog_name"); // anthropic_messages only; absent => library default (true). const prompt_cache: bool = blk: { if (val.get("prompt_cache")) |pc| { if (pc.asBool()) |b| break :blk b; } break :blk true; }; const extra_headers = try parseHeaders(aa, val.get("extra_headers")); return .{ .name = try allocator.dupe(u8, name), .style = style, .dialect = if (dialect) |d| try allocator.dupe(u8, d) else null, .base_url = try allocator.dupe(u8, base_url), .auth_name = try allocator.dupe(u8, auth_name), .model_catalog_name = if (model_catalog_name) |m| try allocator.dupe(u8, m) else null, .prompt_cache = prompt_cache, .extra_headers = extra_headers, }; } // =========================================================================== // Auth resolution // =========================================================================== /// Parse one `auth.` block into a `ResolvedAuth`. All strings are duped /// into `aa` (the Config auth arena) after `${...}` substitution. /// /// `type` is inferred when omitted: a `client_id` means `oauth_device`, a /// `key` means `api_key`. For `api_key`, the credential is resolved eagerly /// from `key` (which is usually `"${env:VAR}"`); an empty result leaves the /// session unresolved (providers using it are later dropped). fn resolveAuth( aa: Allocator, environ_map: *const std.process.Environ.Map, name: []const u8, val: *const toml.Value, ) Error!ResolvedAuth { if (val.* != .table) return error.InvalidAuth; const auth_type: panto.AuthType = blk: { if (tomlStr(val, "type")) |ts| { break :blk std.meta.stringToEnum(panto.AuthType, ts) orelse return error.InvalidAuth; } if (val.get("client_id") != null) break :blk .oauth_device; if (val.get("key") != null) break :blk .api_key; return error.InvalidAuth; }; switch (auth_type) { .api_key => { // `key` after substitution; empty/absent => unresolved. const key = try substField(aa, environ_map, val, "key"); return .{ .name = try aa.dupe(u8, name), .config = .{ .api_key = .{ .key = key } }, .resolved_api_key = key, }; }, .oauth_device => { const dialect = blk: { const ds = tomlStr(val, "dialect") orelse break :blk panto.DeviceDialect.token; break :blk std.meta.stringToEnum(panto.DeviceDialect, ds) orelse return error.InvalidAuth; }; const fmt = blk: { const fs = tomlStr(val, "token_request_format") orelse break :blk panto.TokenRequestFormat.form; break :blk std.meta.stringToEnum(panto.TokenRequestFormat, fs) orelse return error.InvalidAuth; }; const oauth: panto.OAuthDeviceAuth = .{ .dialect = dialect, .client_id = (try substField(aa, environ_map, val, "client_id")) orelse return error.InvalidAuth, .device_code_url = (try substField(aa, environ_map, val, "device_code_url")) orelse return error.InvalidAuth, .token_url = (try substField(aa, environ_map, val, "token_url")) orelse return error.InvalidAuth, .device_poll_url = try substField(aa, environ_map, val, "device_poll_url"), .verification_url = try substField(aa, environ_map, val, "verification_url"), .scope = try substField(aa, environ_map, val, "scope"), .token_request_format = fmt, .redirect_uri = try substField(aa, environ_map, val, "redirect_uri"), .account_id_jwt_claim = try substField(aa, environ_map, val, "account_id_jwt_claim"), .exchange = try parseExchangeFlat(aa, environ_map, val), }; // codex dialect needs a distinct poll endpoint. if (dialect == .codex and oauth.device_poll_url == null) return error.InvalidAuth; return .{ .name = try aa.dupe(u8, name), .config = .{ .oauth_device = oauth }, .resolved_api_key = null, }; }, } } /// Build the optional secondary exchange from flat `exchange_*` keys. Returns /// null when `exchange_url` is absent. fn parseExchangeFlat( aa: Allocator, environ_map: *const std.process.Environ.Map, val: *const toml.Value, ) Error!?panto.ExchangeConfig { const url = (try substField(aa, environ_map, val, "exchange_url")) orelse return null; return .{ .url = url, .method = (try substField(aa, environ_map, val, "exchange_method")) orelse "GET", .token_json_path = (try substField(aa, environ_map, val, "exchange_token_path")) orelse "token", .expires_at_json_path = try substField(aa, environ_map, val, "exchange_expires_path"), .base_url_json_path = try substField(aa, environ_map, val, "exchange_base_url_path"), }; } /// Read a string field from an auth table, run `${...}` substitution, and dupe /// the result into `aa`. Returns null when the key is absent or substitutes to /// the empty string (so an unset `${env:VAR}` reads as "unresolved"). fn substField( aa: Allocator, environ_map: *const std.process.Environ.Map, table: *const toml.Value, key: []const u8, ) Error!?[]const u8 { const raw = tomlStr(table, key) orelse return null; const v = try substitute(aa, environ_map, table, raw); return if (v.len == 0) null else v; } /// Substitute `${name}` placeholders in `raw`. `${env:VAR}` reads the /// environment; any other `${name}` reads the raw value of sibling key `name` /// in the same auth `table`. An unresolved reference (missing env var or /// sibling key) substitutes to the empty string. Result is owned by `aa`. fn substitute( aa: Allocator, environ_map: *const std.process.Environ.Map, table: *const toml.Value, raw: []const u8, ) Error![]const u8 { if (std.mem.indexOf(u8, raw, "${") == null) return aa.dupe(u8, raw); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(aa); var i: usize = 0; while (i < raw.len) { if (raw[i] == '$' and i + 1 < raw.len and raw[i + 1] == '{') { const close = std.mem.indexOfScalarPos(u8, raw, i + 2, '}') orelse { // Unterminated `${` — emit the remainder literally. try out.appendSlice(aa, raw[i..]); break; }; const ref = raw[i + 2 .. close]; const value: []const u8 = if (std.mem.startsWith(u8, ref, "env:")) (environ_map.get(ref[4..]) orelse "") else (tomlStr(table, ref) orelse ""); try out.appendSlice(aa, value); i = close + 1; } else { try out.append(aa, raw[i]); i += 1; } } return out.toOwnedSlice(aa); } /// Parse a `[...headers]` table of string keys/values into `[]panto.Header`, /// allocated in `aa`. A null/absent table yields an empty slice. fn parseHeaders(aa: Allocator, val: ?*const toml.Value) Error![]panto.Header { const v = val orelse return &.{}; if (v.* != .table) return &.{}; var list: std.ArrayList(panto.Header) = .empty; var it = toml.tableIterator(v); while (it.next()) |entry| { const value = entry.value.asString() orelse continue; try list.append(aa, .{ .name = try aa.dupe(u8, entry.key), .value = try aa.dupe(u8, value), }); } return list.toOwnedSlice(aa); } /// Borrow a string field from a table, or null if absent/non-string. fn tomlStr(val: *const toml.Value, key: []const u8) ?[]const u8 { const v = val.get(key) orelse return null; return v.asString(); } 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 }; } // =========================================================================== // Parse helpers // =========================================================================== 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 helper: resolve a single already-parsed document, building its /// `[extensions]` policy from that one layer (layer 0). fn resolveDoc( allocator: Allocator, environ_map: *const std.process.Environ.Map, root: *const toml.Value, ) Error!Config { var srcs = try SourceLists.collect(allocator, &.{root}); errdefer srcs.deinit(allocator); const policy = buildPolicy(allocator, &srcs.rules); return resolve( allocator, environ_map, root, policy, try srcs.paths.toOwnedSlice(allocator), try srcs.rocks.toOwnedSlice(allocator), ); } /// Test helper: build a `Policy` from a `[extensions]` TOML fragment on one /// layer. Panics on parse error (test-only). fn policyFromToml(a: Allocator, src: []const u8) Policy { const doc = parseDoc(a, src) catch unreachable; defer doc.deinit(); var rules: std.ArrayList(Rule) = .empty; collectRules(a, doc.root, 0, &rules) catch unreachable; return buildPolicy(a, &rules); } 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: default allow; a deny blocks its matches" { const a = testing.allocator; const policy = policyFromToml(a, \\[extensions] \\deny = ["std.shell"] ); defer policy.deinit(a); try testing.expect(policy.permits("std.read")); try testing.expect(!policy.permits("std.shell")); } test "Policy.permits: whitelist via deny=[**] plus allow carve-outs" { const a = testing.allocator; const policy = policyFromToml(a, \\[extensions] \\deny = ["**"] \\allow = ["std.*"] ); defer policy.deinit(a); // std.* is more specific than ** → permitted; everything else denied. try testing.expect(policy.permits("std.read")); try testing.expect(!policy.permits("other.read")); } test "Policy.permits: specific allow carves one name out of a broad deny" { const a = testing.allocator; const policy = policyFromToml(a, \\[extensions] \\deny = ["agent.*"] \\allow = ["agent.rules"] ); defer policy.deinit(a); try testing.expect(policy.permits("agent.rules")); // allow more specific try testing.expect(!policy.permits("agent.skills")); // only the deny matches } test "Policy.permits: deny beats allow at an exact tie" { const a = testing.allocator; const policy = policyFromToml(a, \\[extensions] \\allow = ["agent.rules"] \\deny = ["agent.rules"] ); defer policy.deinit(a); try testing.expect(!policy.permits("agent.rules")); } test "Policy.permits: higher layer's broad rule beats lower layer's specific one" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var pb: [std.fs.max_path_bytes]u8 = undefined; const tpn = try tmp.dir.realPath(testing.io, &pb); const tp = pb[0..tpn]; // Base pins one specific name; user broadly denies the group. const base = try std.fs.path.join(a, &.{ tp, "b.toml" }); defer a.free(base); try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.toml", .data = \\[extensions] \\allow = ["agent.subagents"] }); const user = try std.fs.path.join(a, &.{ tp, "u.toml" }); defer a.free(user); try tmp.dir.writeFile(testing.io, .{ .sub_path = "u.toml", .data = \\[extensions] \\deny = ["agent.*"] }); var cfg = try loadFromPaths(a, testing.io, &env, &.{ base, user }); defer cfg.deinit(); // Higher (user) layer's broad deny wins over base's specific allow. try testing.expect(!cfg.extensions.permits("agent.subagents")); } test "extensions.paths and rocks parse per layer" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[extensions] \\paths = ["./dev/ext"] \\rocks = ["panto-agent 1.4.2-1"] ; var cfg = try loadFromString(a, &env, src); defer cfg.deinit(); try testing.expectEqual(@as(usize, 1), cfg.ext_paths.len); try testing.expectEqualStrings("./dev/ext", cfg.ext_paths[0].value); try testing.expectEqual(@as(usize, 1), cfg.ext_rocks.len); try testing.expectEqualStrings("panto-agent 1.4.2-1", cfg.ext_rocks[0].value); } /// Standard api_key provider+auth source for an anthropic provider whose key /// comes from `ANTHROPIC_API_KEY`. Keeps the many model-knob tests terse. const anthropic_env_src = \\[providers.anthropic] \\style = "anthropic_messages" \\base_url = "https://api.anthropic.com" \\auth = "anthropic_api" \\ \\[auth.anthropic_api] \\key = "${env:ANTHROPIC_API_KEY}" ; test "resolve: api_key provider is dropped when its ${env} key is unset" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); // Unresolved api_key ⇒ the provider disappears (export the key to get it // back). The auth session itself still exists. try testing.expectEqual(@as(usize, 0), cfg.providers.len); try testing.expect(cfg.provider("anthropic") == null); const auth = cfg.auth("anthropic_api").?; try testing.expect(auth.resolved_api_key == null); // `type` was inferred from the presence of `key`. try testing.expectEqual(panto.AuthType.api_key, @as(panto.AuthType, auth.config)); } test "resolve: api_key session 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 doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); var cfg = try resolveDoc(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("anthropic_api", p.auth_name); try testing.expectEqualStrings("sk-ant-xyz", cfg.auth("anthropic_api").?.resolved_api_key.?); } test "resolve: missing provider auth is an error" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.anthropic] \\style = "anthropic_messages" \\base_url = "https://api.anthropic.com" ; const doc = try parseDoc(a, src); defer doc.deinit(); try testing.expectError(error.MissingProviderAuth, resolveDoc(a, &env, doc.root)); } test "resolve: provider naming an unknown auth session is an error" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.anthropic] \\style = "anthropic_messages" \\base_url = "https://api.anthropic.com" \\auth = "ghost" ; const doc = try parseDoc(a, src); defer doc.deinit(); try testing.expectError(error.UnknownProviderAuth, resolveDoc(a, &env, doc.root)); } test "resolve: ${env:VAR} and ${sibling} substitution in auth values" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); try env.put("MY_KEY", "sk-from-env"); const src = \\[providers.openai] \\style = "openai_chat" \\base_url = "https://api.openai.com/v1" \\auth = "k" \\ \\[auth.k] \\key = "${env:MY_KEY}" \\ \\[providers.gh] \\style = "openai_chat" \\base_url = "https://example.com" \\auth = "gh" \\ \\[auth.gh] \\client_id = "abc" \\domain = "github.com" \\device_code_url = "https://${domain}/login/device/code" \\token_url = "https://${domain}/login/oauth/access_token" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); // env substitution resolves the literal key. try testing.expectEqualStrings("sk-from-env", cfg.auth("k").?.resolved_api_key.?); // sibling-key substitution templates the device URLs (type inferred from // client_id). const oauth = cfg.auth("gh").?.config.oauth_device; try testing.expectEqualStrings("https://github.com/login/device/code", oauth.device_code_url); try testing.expectEqualStrings("https://github.com/login/oauth/access_token", oauth.token_url); } test "resolve: literal key resolves without substitution" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[auth.k] \\key = "sk-literal" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqualStrings("sk-literal", cfg.auth("k").?.resolved_api_key.?); } test "resolve: invalid auth type is an error" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[auth.bad] \\type = "magic" ; const doc = try parseDoc(a, src); defer doc.deinit(); try testing.expectError(error.InvalidAuth, resolveDoc(a, &env, doc.root)); } test "resolve: oauth_device (token dialect) with exchange parses" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[auth.github_copilot] \\client_id = "Iv1.b507a08c87ecfe98" \\domain = "github.com" \\device_code_url = "https://${domain}/login/device/code" \\token_url = "https://${domain}/login/oauth/access_token" \\scope = "read:user" \\exchange_url = "https://api.${domain}/copilot_internal/v2/token" \\exchange_expires_path = "expires_at" \\exchange_base_url_path = "endpoints.api" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); const oauth = cfg.auth("github_copilot").?.config.oauth_device; // type + dialect inferred/defaulted. try testing.expectEqual(panto.DeviceDialect.token, oauth.dialect); try testing.expectEqualStrings("Iv1.b507a08c87ecfe98", oauth.client_id); try testing.expectEqualStrings("https://github.com/login/device/code", oauth.device_code_url); try testing.expect(oauth.exchange != null); try testing.expectEqualStrings("https://api.github.com/copilot_internal/v2/token", oauth.exchange.?.url); try testing.expectEqualStrings("token", oauth.exchange.?.token_json_path); // default try testing.expectEqualStrings("endpoints.api", oauth.exchange.?.base_url_json_path.?); } test "resolve: oauth_device codex dialect requires a poll url" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[auth.codex] \\type = "oauth_device" \\dialect = "codex" \\client_id = "app_x" \\device_code_url = "https://auth.openai.com/api/accounts/deviceauth/usercode" \\token_url = "https://auth.openai.com/oauth/token" ; const doc = try parseDoc(a, src); defer doc.deinit(); try testing.expectError(error.InvalidAuth, resolveDoc(a, &env, doc.root)); } test "resolve: provider extra_headers flow into the built provider config" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.copilot] \\style = "openai_chat" \\base_url = "https://api.individual.githubcopilot.com" \\auth = "k" \\ \\[providers.copilot.extra_headers] \\Copilot-Integration-Id = "vscode-chat" \\X-Initiator = "user" \\ \\[auth.k] \\type = "api_key" \\key = "tok" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); const p = cfg.provider("copilot").?; try testing.expectEqual(@as(usize, 2), p.extra_headers.len); var models = models_toml.ModelRegistry.init(a); defer models.deinit(); const pc = try buildProviderConfig(&cfg, &models, .{ .provider = "copilot", .model = "gpt-4o" }); try testing.expectEqual(@as(usize, 2), pc.openai_chat.extra_headers.len); try testing.expectEqualStrings("tok", pc.openai_chat.api_key); } test "resolve: provider model_catalog_name is captured" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.copilot] \\style = "openai_chat" \\base_url = "https://api.individual.githubcopilot.com" \\auth = "k" \\model_catalog_name = "github-copilot" \\ \\[auth.k] \\key = "tok" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqualStrings("github-copilot", cfg.provider("copilot").?.model_catalog_name.?); } test "resolve: openai_responses codex dialect maps to internal API style" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.codex] \\style = "openai_responses" \\dialect = "codex" \\base_url = "https://chatgpt.com/backend-api" \\auth = "k" \\ \\[auth.k] \\key = "tok" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); try testing.expectEqual(APIStyle.openai_codex_responses, cfg.provider("codex").?.style); var defs = models_toml.ModelRegistry.init(a); defer defs.deinit(); const pc = try buildProviderConfig(&cfg, &defs, .{ .provider = "codex", .model = "gpt-5.5" }); try testing.expectEqual(APIStyle.openai_codex_responses, pc.style()); } test "resolve: internal openai_codex_responses style is not user-facing" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.codex] \\style = "openai_codex_responses" \\base_url = "https://chatgpt.com/backend-api" \\auth = "k" \\ \\[auth.k] \\key = "tok" ; const doc = try parseDoc(a, src); defer doc.deinit(); try testing.expectError(error.InvalidProvider, resolveDoc(a, &env, doc.root)); } test "resolve: prompt_cache defaults true and is parsed when set" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.anthropic] \\style = "anthropic_messages" \\base_url = "https://api.anthropic.com" \\auth = "k" \\ \\[providers.anthropic_nocache] \\style = "anthropic_messages" \\base_url = "https://api.anthropic.com" \\auth = "k" \\prompt_cache = false \\ \\[auth.k] \\type = "api_key" \\key = "sk-ant" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); // Absent => library default (true). try testing.expectEqual(true, cfg.provider("anthropic").?.prompt_cache); // Explicit false is honoured. try testing.expectEqual(false, cfg.provider("anthropic_nocache").?.prompt_cache); } test "buildProviderConfig: anthropic oauth_device uses bearer auth" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.copilot_anthropic] \\style = "anthropic_messages" \\base_url = "https://api.individual.githubcopilot.com" \\auth = "github_copilot" \\ \\[auth.github_copilot] \\type = "oauth_device" \\client_id = "Iv1.x" \\device_code_url = "https://github.com/login/device/code" \\token_url = "https://github.com/login/oauth/access_token" ; var cfg = try loadFromString(a, &env, src); defer cfg.deinit(); var defs = models_toml.ModelRegistry.init(a); defer defs.deinit(); const pc = try buildProviderConfig(&cfg, &defs, .{ .provider = "copilot_anthropic", .model = "claude-sonnet-4-5" }); try testing.expectEqual(APIStyle.anthropic_messages, pc.style()); try testing.expect(pc.anthropic_messages.use_bearer_auth); } test "buildProviderConfig: prompt_cache flows from provider into anthropic config" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[providers.anthropic] \\style = "anthropic_messages" \\base_url = "https://api.anthropic.com" \\auth = "k" \\prompt_cache = false \\ \\[auth.k] \\type = "api_key" \\key = "sk-ant" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); var defs = models_toml.ModelRegistry.init(a); defer defs.deinit(); const pc = try buildProviderConfig(&cfg, &defs, .{ .provider = "anthropic", .model = "sonnet" }); try testing.expectEqual(APIStyle.anthropic_messages, pc.style()); try testing.expectEqual(false, pc.anthropic_messages.prompt_cache); } 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, resolveDoc(a, &env, doc.root)); } test "extensions policy: a pattern in both allow and deny resolves to deny" { const a = testing.allocator; var env = emptyEnv(a); defer env.deinit(); const src = \\[extensions] \\allow = ["std.*"] \\deny = ["std.*"] ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(a, &env, doc.root); defer cfg.deinit(); // No longer an error — deny wins the exact tie. try testing.expect(!cfg.extensions.permits("std.read")); } 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" \\auth = "anthropic_api" \\ \\[auth.anthropic_api] \\type = "api_key" \\key = "sys-key" \\ \\[extensions] \\deny = ["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" \\auth = "openai_api" \\ \\[auth.openai_api] \\type = "api_key" \\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); // The base layer's extensions.deny survives (project adds no override). try testing.expect(!cfg.extensions.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" \\auth = "openai_api" \\ \\[auth.openai_api] \\type = "api_key" \\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 doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); var cfg = try resolveDoc(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" \\auth = "k" \\ \\[auth.k] \\type = "api_key" \\key = "sk-test" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(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 doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); var cfg = try resolveDoc(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 doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); var cfg = try resolveDoc(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 resolveDoc(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" \\auth = "k" \\ \\[auth.k] \\type = "api_key" \\key = "sk" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(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 resolveDoc(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 = anthropic_env_src ++ \\ \\[compaction] \\keep_verbatim = 12345 \\model = "anthropic:haiku" ; const doc = try parseDoc(a, src); defer doc.deinit(); var cfg = try resolveDoc(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 doc = try parseDoc(a, anthropic_env_src); defer doc.deinit(); var cfg = try resolveDoc(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 = anthropic_env_src ++ \\ \\[compaction] \\model = "openai:gpt-4o" ; const doc = try parseDoc(a, src); defer doc.deinit(); try testing.expectError(error.UnknownDefaultProvider, resolveDoc(a, &env, doc.root)); }