summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/auth_manager.zig12
-rw-r--r--src/config_file.zig276
-rw-r--r--src/luarocks_runtime.zig52
-rw-r--r--src/subcommand.zig14
4 files changed, 221 insertions, 133 deletions
diff --git a/src/auth_manager.zig b/src/auth_manager.zig
index 87b20ae..e26e28a 100644
--- a/src/auth_manager.zig
+++ b/src/auth_manager.zig
@@ -95,7 +95,10 @@ pub const AuthManager = struct {
.oauth_device => |oauth| {
_ = self.cred_arena.reset(.retain_capacity);
const arena = self.cred_arena.allocator();
- const cred = try self.resolveOAuth(arena, oauth, prov.auth_name, force, presenter);
+ // The provider's identity headers ride on every auth HTTP call
+ // (exchange/device/poll), the same way they ride on the model
+ // request.
+ const cred = try self.resolveOAuth(arena, oauth, prov.auth_name, prov.extra_headers, force, presenter);
try patchCredential(live, prov, cred, arena);
},
}
@@ -109,6 +112,7 @@ pub const AuthManager = struct {
arena: Allocator,
oauth: panto.OAuthDeviceAuth,
name: []const u8,
+ headers: []const panto.Header,
force: bool,
presenter: ?panto.Presenter,
) anyerror!panto.ResolvedCredential {
@@ -120,7 +124,7 @@ pub const AuthManager = struct {
// Working token set; strings borrow from `loaded` or `arena`.
var ts: panto.TokenSet = if (loaded) |l| l.value else blk: {
const p = presenter orelse return Error.LoginRequired;
- const toks = try panto.oauthLogin(arena, self.io, self.client, oauth, p);
+ const toks = try panto.oauthLogin(arena, self.io, self.client, oauth, p, headers);
const fresh = try panto.tokensToTokenSet(arena, oauth, toks, now);
try self.saveToken(name, fresh);
break :blk fresh;
@@ -128,7 +132,7 @@ pub const AuthManager = struct {
// Refresh the access token if it's near expiry (or forced).
if ((force or panto.needsRefresh(ts, now, refresh_margin_secs)) and ts.refresh_token != null) {
- const toks = try panto.refreshTokens(arena, self.client, oauth, ts.refresh_token.?);
+ const toks = try panto.refreshTokens(arena, self.client, oauth, ts.refresh_token.?, headers);
var refreshed = try panto.tokensToTokenSet(arena, oauth, toks, now);
// Refresh responses may omit the refresh token / id_token; keep
// the prior values so the session stays renewable.
@@ -144,7 +148,7 @@ pub const AuthManager = struct {
if (oauth.exchange) |exchange| {
if (force or panto.needsExchange(oauth, ts, now, refresh_margin_secs)) {
const access = ts.access_token orelse return Error.LoginRequired;
- const ex = try panto.runExchange(arena, self.client, exchange, access);
+ const ex = try panto.runExchange(arena, self.client, exchange, access, headers);
ts.exchange = ex;
try self.saveToken(name, ts);
}
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" {
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
index 3cfd02f..f0438d4 100644
--- a/src/luarocks_runtime.zig
+++ b/src/luarocks_runtime.zig
@@ -282,9 +282,12 @@ const default_base_config =
\\# replace the lower one wholesale.
\\#
\\# Every networked provider names an `[auth.<name>]` session via
- \\# `auth = "<name>"`. An api_key session whose env var is missing at
- \\# runtime stays selectable, but its first request fails with a clear
- \\# auth error.
+ \\# `auth = "<name>"`. An api_key session whose `${env:VAR}` is unset
+ \\# resolves to empty, and a provider with an empty key is dropped — so
+ \\# these defaults disappear unless you've exported the matching key.
+ \\#
+ \\# Values support `${...}` substitution: `${env:VAR}` reads the
+ \\# environment, `${other_key}` reads a sibling key in the same section.
\\
\\[providers.openai]
\\style = "openai_chat"
@@ -292,8 +295,7 @@ const default_base_config =
\\auth = "openai_api"
\\
\\[auth.openai_api]
- \\type = "api_key"
- \\key_env_var = "OPENAI_API_KEY"
+ \\key = "${env:OPENAI_API_KEY}"
\\
\\[providers.anthropic]
\\style = "anthropic_messages"
@@ -301,8 +303,7 @@ const default_base_config =
\\auth = "anthropic_api"
\\
\\[auth.anthropic_api]
- \\type = "api_key"
- \\key_env_var = "ANTHROPIC_API_KEY"
+ \\key = "${env:ANTHROPIC_API_KEY}"
\\
\\# Pick the default model with `<provider>:<alias>`. The alias is
\\# resolved against models.toml; an unknown alias is used verbatim as
@@ -312,40 +313,30 @@ const default_base_config =
\\# model = "anthropic:claude-sonnet-4-20250514"
\\
\\# GitHub Copilot subscription (OAuth device login). Run a turn against
- \\# `copilot:<model>` and panto will guide you through device login.
+ \\# `copilot:<model>` and panto guides you through device login. The
+ \\# provider's extra_headers (the editor identity) are reused on the auth
+ \\# calls too. For GitHub Enterprise, set `domain = "company.ghe.com"`.
\\#
\\# [providers.copilot]
\\# style = "openai_chat"
- \\# base_url = "https://api.individual.githubcopilot.com"
+ \\# base_url = "https://api.individual.githubcopilot.com" # fallback; the exchange overrides it
\\# auth = "github_copilot"
\\#
\\# [providers.copilot.extra_headers]
- \\# User-Agent = "GitHubCopilotChat/0.26.7"
- \\# Editor-Version = "vscode/1.99.0"
- \\# Editor-Plugin-Version = "copilot-chat/0.26.7"
+ \\# User-Agent = "GitHubCopilotChat/0.35.0"
+ \\# Editor-Version = "vscode/1.107.0"
+ \\# Editor-Plugin-Version = "copilot-chat/0.35.0"
\\# Copilot-Integration-Id = "vscode-chat"
- \\# X-Initiator = "user"
\\#
\\# [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]
- \\# method = "GET"
- \\# 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]
- \\# User-Agent = "GitHubCopilotChat/0.26.7"
- \\# Editor-Version = "vscode/1.99.0"
- \\# Editor-Plugin-Version = "copilot-chat/0.26.7"
- \\# 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"
\\
\\# OpenAI Codex via a ChatGPT subscription (device login). Codex speaks the
\\# OpenAI Responses API, not Chat Completions, so style = "openai_responses".
@@ -362,7 +353,6 @@ const default_base_config =
\\# originator = "codex_cli_rs"
\\#
\\# [auth.openai_codex]
- \\# type = "oauth_device"
\\# dialect = "codex"
\\# client_id = "app_EMoamEEZ73f0CkXaXp7hrann"
\\# device_code_url = "https://auth.openai.com/api/accounts/deviceauth/usercode"
diff --git a/src/subcommand.zig b/src/subcommand.zig
index b145d4b..bb46941 100644
--- a/src/subcommand.zig
+++ b/src/subcommand.zig
@@ -491,12 +491,22 @@ fn authLogin(
defer panto.deinit();
const client = panto.httpClient();
+ // The auth HTTP calls carry the identity headers of a provider that uses
+ // this session (e.g. Copilot's editor headers). 1:1 in practice; if no
+ // provider references it yet, send none.
+ const headers: []const panto.Header = blk: {
+ for (cfg.providers) |p| {
+ if (std.mem.eql(u8, p.auth_name, name)) break :blk p.extra_headers;
+ }
+ break :blk &.{};
+ };
+
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const aa = arena.allocator();
var presenter = CliPresenter{ .io = io };
- const toks = panto.oauthLogin(aa, io, client, oauth, presenter.presenter()) catch |err| {
+ const toks = panto.oauthLogin(aa, io, client, oauth, presenter.presenter(), headers) catch |err| {
try out.print("login failed: {t}\n", .{err});
return;
};
@@ -508,7 +518,7 @@ fn authLogin(
// immediately usable and we surface any exchange error during login.
if (oauth.exchange) |exchange| {
if (ts.access_token) |access| {
- ts.exchange = panto.runExchange(aa, client, exchange, access) catch |err| blk: {
+ ts.exchange = panto.runExchange(aa, client, exchange, access, headers) catch |err| blk: {
try out.print("note: token exchange failed ({t}); will retry on first use\n", .{err});
break :blk null;
};