//! 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 `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 `:`. //! - `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 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, }; /// 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 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, 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); self.allocator.free(self.auths); 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); // 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 }; } /// `$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; } /// 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(); return resolve(allocator, environ_map, doc.root); } /// 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 { // 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); } // 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); } 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, .tools = tools, .extensions = extensions, .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 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")); } /// 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 resolve(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 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("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, resolve(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, resolve(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 resolve(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 resolve(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, resolve(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 resolve(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, resolve(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 resolve(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 resolve(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 resolve(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, resolve(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 resolve(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 resolve(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, 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" \\auth = "anthropic_api" \\ \\[auth.anthropic_api] \\type = "api_key" \\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" \\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); // 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" \\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 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" \\auth = "k" \\ \\[auth.k] \\type = "api_key" \\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 doc = try parseDoc(a, anthropic_env_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 doc = try parseDoc(a, anthropic_env_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" \\auth = "k" \\ \\[auth.k] \\type = "api_key" \\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 = anthropic_env_src ++ \\ \\[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 doc = try parseDoc(a, anthropic_env_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 = anthropic_env_src ++ \\ \\[compaction] \\model = "openai:gpt-4o" ; const doc = try parseDoc(a, src); defer doc.deinit(); try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root)); }