summaryrefslogtreecommitdiff
path: root/src/config_file.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 14:24:31 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commit20e6e08a42240aef0b36ecf627cbcde921912071 (patch)
treefd7e1f860f28ffa41d33416fb6277a95e530191e /src/config_file.zig
parentfbee69ca8c4b485b7b2d6fe7448b13367c4716c8 (diff)
auth: flatten [auth.<name>] config + ${...} substitution
Collapse the auth schema: infer `type` from keys, replace key/key_env_var with a single `key` (resolved via substitution), flatten the exchange to flat `exchange_*` keys, and drop the auth-level headers tables in favor of reusing the provider's `extra_headers` on the auth HTTP calls. Add `${sibling}` and `${env:VAR}` substitution over auth values (GitHub Enterprise = set `domain`). Restore api_key-empty → provider-drop. Update default config, docs, and tests.
Diffstat (limited to 'src/config_file.zig')
-rw-r--r--src/config_file.zig276
1 files changed, 180 insertions, 96 deletions
diff --git a/src/config_file.zig b/src/config_file.zig
index cac003a..1823177 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -21,12 +21,13 @@
//! After merge, the document is resolved into a `Config`:
//!
//! - Every `auth.<name>` becomes a `ResolvedAuth` — a named auth session
-//! (`api_key` or `oauth_device`). For `api_key`, the key is resolved
-//! eagerly from the literal `key` (preferred) or the `key_env_var`
-//! environment variable; an absent env var leaves the session
-//! **unresolved** (its first request fails with a clear error) but never
-//! drops it. OAuth sessions carry their flow config and are resolved at
-//! turn time.
+//! (`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.<name>` becomes a `Provider`. A networked provider
//! **must** name its auth session via `auth = "<name>"` (clean break:
//! provider-level `api_key`/`api_key_env_var` are no longer accepted).
@@ -95,6 +96,14 @@ pub const Provider = struct {
}
};
+/// 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.<name>`). 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
@@ -303,8 +312,8 @@ pub const Error = error{
InvalidModelRef,
UnknownDefaultProvider,
PolicyConflict,
- /// An `[auth.<name>]` block is malformed (missing `type`, unknown type,
- /// or a required field for its type is absent).
+ /// An `[auth.<name>]` 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 = "<name>"`.
MissingProviderAuth,
@@ -545,14 +554,27 @@ fn resolve(
// Every provider must name an auth session that exists.
for (providers.items) |p| {
- var found = false;
- for (auths.items) |a| {
- if (std.mem.eql(u8, a.name, p.auth_name)) {
- found = true;
- break;
+ 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;
}
}
- if (!found) return error.UnknownProviderAuth;
+ providers.shrinkRetainingCapacity(w);
}
// Tool / extension policies.
@@ -688,9 +710,13 @@ fn resolveProvider(
// Auth resolution
// ===========================================================================
-/// Parse one `auth.<name>` block into a `ResolvedAuth`. All strings are
-/// duped into `aa` (the Config auth arena). For `api_key` the credential is
-/// resolved eagerly (literal `key`, else `key_env_var` from the environment).
+/// Parse one `auth.<name>` 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,
@@ -699,26 +725,23 @@ fn resolveAuth(
) Error!ResolvedAuth {
if (val.* != .table) return error.InvalidAuth;
- const type_str = (val.get("type") orelse return error.InvalidAuth).asString() orelse
+ 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;
- const auth_type = std.meta.stringToEnum(panto.AuthType, type_str) orelse return error.InvalidAuth;
+ };
switch (auth_type) {
.api_key => {
- const key: ?[]const u8 = if (tomlStr(val, "key")) |s| try aa.dupe(u8, s) else null;
- const key_env_var: ?[]const u8 = if (tomlStr(val, "key_env_var")) |s| try aa.dupe(u8, s) else null;
- // Eagerly resolve: literal wins over env var.
- const resolved: ?[]const u8 = blk: {
- if (key) |k| break :blk k;
- if (key_env_var) |ev| {
- if (environ_map.get(ev)) |actual| break :blk try aa.dupe(u8, actual);
- }
- break :blk null;
- };
+ // `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, .key_env_var = key_env_var } },
- .resolved_api_key = resolved,
+ .config = .{ .api_key = .{ .key = key } },
+ .resolved_api_key = key,
};
},
.oauth_device => {
@@ -732,17 +755,16 @@ fn resolveAuth(
};
const oauth: panto.OAuthDeviceAuth = .{
.dialect = dialect,
- .client_id = try aaDupReq(aa, val, "client_id"),
- .device_code_url = try aaDupReq(aa, val, "device_code_url"),
- .token_url = try aaDupReq(aa, val, "token_url"),
- .device_poll_url = try aaDupOpt(aa, val, "device_poll_url"),
- .verification_url = try aaDupOpt(aa, val, "verification_url"),
- .scope = try aaDupOpt(aa, val, "scope"),
+ .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 aaDupOpt(aa, val, "redirect_uri"),
- .account_id_jwt_claim = try aaDupOpt(aa, val, "account_id_jwt_claim"),
- .headers = try parseHeaders(aa, val.get("headers")),
- .exchange = try parseExchange(aa, val.get("exchange")),
+ .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;
@@ -755,20 +777,73 @@ fn resolveAuth(
}
}
-/// Parse an optional `[auth.<name>.exchange]` sub-table.
-fn parseExchange(aa: Allocator, val: ?*const toml.Value) Error!?panto.ExchangeConfig {
- const v = val orelse return null;
- if (v.* != .table) return error.InvalidAuth;
+/// 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 .{
- .method = if (tomlStr(v, "method")) |s| try aa.dupe(u8, s) else "GET",
- .url = try aaDupReq(aa, v, "url"),
- .token_json_path = if (tomlStr(v, "token_json_path")) |s| try aa.dupe(u8, s) else "token",
- .expires_at_json_path = try aaDupOpt(aa, v, "expires_at_json_path"),
- .base_url_json_path = try aaDupOpt(aa, v, "base_url_json_path"),
- .headers = try parseHeaders(aa, v.get("headers")),
+ .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 {
@@ -786,19 +861,6 @@ fn parseHeaders(aa: Allocator, val: ?*const toml.Value) Error![]panto.Header {
return list.toOwnedSlice(aa);
}
-/// Read a required string field, duping into `aa`. Errors `InvalidAuth` if
-/// absent or non-string.
-fn aaDupReq(aa: Allocator, val: *const toml.Value, key: []const u8) Error![]const u8 {
- const s = tomlStr(val, key) orelse return error.InvalidAuth;
- return aa.dupe(u8, s);
-}
-
-/// Read an optional string field, duping into `aa` (null if absent).
-fn aaDupOpt(aa: Allocator, val: *const toml.Value, key: []const u8) Error!?[]const u8 {
- const s = tomlStr(val, key) orelse return null;
- return try aa.dupe(u8, s);
-}
-
/// 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;
@@ -951,11 +1013,10 @@ const anthropic_env_src =
\\auth = "anthropic_api"
\\
\\[auth.anthropic_api]
- \\type = "api_key"
- \\key_env_var = "ANTHROPIC_API_KEY"
+ \\key = "${env:ANTHROPIC_API_KEY}"
;
-test "resolve: provider survives with unresolved auth when env var is absent" {
+test "resolve: api_key provider is dropped when its ${env} key is unset" {
const a = testing.allocator;
var env = emptyEnv(a);
defer env.deinit();
@@ -965,16 +1026,14 @@ test "resolve: provider survives with unresolved auth when env var is absent" {
var cfg = try resolve(a, &env, doc.root);
defer cfg.deinit();
- // Clean break: the provider stays selectable even with no key resolved.
- try testing.expectEqual(@as(usize, 1), cfg.providers.len);
+ // 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);
-
- // Its built config carries an empty api_key placeholder.
- var models = models_toml.ModelRegistry.init(a);
- defer models.deinit();
- const pc = try buildProviderConfig(&cfg, &models, .{ .provider = "anthropic", .model = "x" });
- try testing.expectEqualStrings("", pc.anthropic_messages.api_key);
+ // `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" {
@@ -1024,11 +1083,11 @@ test "resolve: provider naming an unknown auth session is an error" {
try testing.expectError(error.UnknownProviderAuth, resolve(a, &env, doc.root));
}
-test "resolve: auth literal key wins over key_env_var" {
+test "resolve: ${env:VAR} and ${sibling} substitution in auth values" {
const a = testing.allocator;
var env = emptyEnv(a);
defer env.deinit();
- try env.put("KEY_FROM_ENV", "env-value");
+ try env.put("MY_KEY", "sk-from-env");
const src =
\\[providers.openai]
@@ -1037,16 +1096,46 @@ test "resolve: auth literal key wins over key_env_var" {
\\auth = "k"
\\
\\[auth.k]
- \\type = "api_key"
- \\key = "inline-value"
- \\key_env_var = "KEY_FROM_ENV"
+ \\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();
- try testing.expectEqualStrings("inline-value", cfg.auth("k").?.resolved_api_key.?);
+ // 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" {
@@ -1068,33 +1157,28 @@ test "resolve: oauth_device (token dialect) with exchange parses" {
defer env.deinit();
const src =
\\[auth.github_copilot]
- \\type = "oauth_device"
- \\dialect = "token"
\\client_id = "Iv1.b507a08c87ecfe98"
- \\device_code_url = "https://github.com/login/device/code"
- \\token_url = "https://github.com/login/oauth/access_token"
+ \\domain = "github.com"
+ \\device_code_url = "https://${domain}/login/device/code"
+ \\token_url = "https://${domain}/login/oauth/access_token"
\\scope = "read:user"
- \\
- \\[auth.github_copilot.exchange]
- \\url = "https://api.github.com/copilot_internal/v2/token"
- \\token_json_path = "token"
- \\expires_at_json_path = "expires_at"
- \\base_url_json_path = "endpoints.api"
- \\
- \\[auth.github_copilot.exchange.headers]
- \\Copilot-Integration-Id = "vscode-chat"
+ \\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.?);
- try testing.expectEqual(@as(usize, 1), oauth.exchange.?.headers.len);
- try testing.expectEqualStrings("Copilot-Integration-Id", oauth.exchange.?.headers[0].name);
}
test "resolve: oauth_device codex dialect requires a poll url" {