summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--libpanto/src/auth.zig180
-rw-r--r--libpanto/src/config.zig18
-rw-r--r--libpanto/src/public.zig17
-rw-r--r--src/config_file.zig590
-rw-r--r--src/luarocks_runtime.zig62
5 files changed, 740 insertions, 127 deletions
diff --git a/libpanto/src/auth.zig b/libpanto/src/auth.zig
new file mode 100644
index 0000000..012e495
--- /dev/null
+++ b/libpanto/src/auth.zig
@@ -0,0 +1,180 @@
+//! Provider authentication: named auth sessions resolved into request-ready
+//! credentials before a provider stream opens.
+//!
+//! A provider names the auth session it uses (`auth = "<name>"`); core
+//! resolves that session into a `ResolvedCredential` (a bearer/api-key value,
+//! an optional dynamic `base_url`, and optional auth-derived request headers).
+//! This covers three families with one configuration shape:
+//!
+//! - `api_key` — static key from a literal or an environment variable.
+//! - `oauth_device` — OAuth 2.0 device-authorization flow. Two dialects:
+//! * `token` — standard device flow (GitHub Copilot). The poll endpoint
+//! returns the OAuth token response directly.
+//! * `codex` — OpenAI Codex device flow. The poll endpoint returns an
+//! authorization code plus a server-generated PKCE verifier; core
+//! exchanges those at `token_url` for `{access,refresh,id}_token`.
+//!
+//! This module owns the *mechanism* (config types, the persisted token shape,
+//! and — added incrementally — the HTTP flows and refresh lifecycle). It is
+//! UI- and filesystem-policy-agnostic: token storage takes a directory path,
+//! and interactive device-code prompts are delivered through a caller-supplied
+//! `Presenter`. That keeps libpanto reusable while the embedder owns where
+//! `$PANTO_HOME` is and how a device code is shown to the user.
+
+const std = @import("std");
+const config_mod = @import("config.zig");
+
+/// A single request header. Re-exported from `config.zig` so callers can
+/// build auth-derived headers and provider `extra_headers` from one type.
+pub const Header = config_mod.Header;
+
+// ===========================================================================
+// Auth configuration (parsed from `[auth.<name>]`)
+// ===========================================================================
+
+pub const AuthType = enum { api_key, oauth_device };
+
+/// Device-flow completion shape. See the module doc.
+pub const DeviceDialect = enum { token, codex };
+
+/// Wire encoding for the `token` dialect's device-code / poll request bodies.
+pub const TokenRequestFormat = enum { form, json };
+
+/// Static API-key auth. `key` (a literal) wins over `key_env_var`.
+pub const ApiKeyAuth = struct {
+ key: ?[]const u8 = null,
+ key_env_var: ?[]const u8 = null,
+};
+
+/// A secondary token exchange run after the durable OAuth token is obtained
+/// (GitHub Copilot's `/copilot_internal/v2/token`). The exchange result —
+/// not the OAuth token — becomes the request credential, and may also
+/// override the provider `base_url`.
+pub const ExchangeConfig = struct {
+ method: []const u8 = "GET",
+ url: []const u8,
+ /// Which stored token to send as the exchange request's bearer.
+ bearer: BearerSource = .oauth_access_token,
+ /// Dotted JSON path to the credential in the exchange response.
+ token_json_path: []const u8 = "token",
+ /// Dotted JSON path to the credential's unix-seconds expiry, if any.
+ expires_at_json_path: ?[]const u8 = null,
+ /// Dotted JSON path to a dynamic `base_url` in the exchange response.
+ base_url_json_path: ?[]const u8 = null,
+ /// Headers sent with the exchange request (e.g. Copilot editor identity).
+ headers: []const Header = &.{},
+
+ pub const BearerSource = enum { oauth_access_token };
+};
+
+/// OAuth 2.0 device-authorization auth.
+pub const OAuthDeviceAuth = struct {
+ dialect: DeviceDialect = .token,
+ client_id: []const u8,
+ /// Endpoint that issues the device/user code.
+ device_code_url: []const u8,
+ /// `token` dialect: poll-for-token and refresh endpoint.
+ /// `codex` dialect: authorization-code exchange and refresh endpoint.
+ token_url: []const u8,
+ /// `codex` dialect: endpoint polled for the authorization code.
+ device_poll_url: ?[]const u8 = null,
+ /// Browser URL shown to the user (codex). For the `token` dialect the
+ /// verification URI comes from the device-code response.
+ verification_url: ?[]const u8 = null,
+ scope: ?[]const u8 = null,
+ /// Request encoding for the `token` dialect device-code / poll bodies.
+ token_request_format: TokenRequestFormat = .form,
+ /// `codex` dialect: redirect URI sent in the code exchange.
+ redirect_uri: ?[]const u8 = null,
+ /// `codex` dialect: JWT claim (on the id_token) holding the account id,
+ /// e.g. `https://api.openai.com/auth`. When set, the resolver extracts an
+ /// account id and emits it as a header (see provider wiring).
+ account_id_jwt_claim: ?[]const u8 = null,
+ /// Headers sent with the device-code / poll / token requests.
+ headers: []const Header = &.{},
+ /// Optional post-login token exchange (Copilot).
+ exchange: ?ExchangeConfig = null,
+};
+
+/// One named auth session's configuration. The union tag mirrors the TOML
+/// `type` field.
+pub const AuthConfig = union(AuthType) {
+ api_key: ApiKeyAuth,
+ oauth_device: OAuthDeviceAuth,
+};
+
+// ===========================================================================
+// Persisted token state (`$PANTO_HOME/auth/<name>.json`)
+// ===========================================================================
+
+/// Durable auth state for one session. Only the fields relevant to the auth
+/// type are populated. Treat the on-disk file like a password.
+pub const TokenSet = struct {
+ /// `"api_key"` | `"oauth_device"` — records which family wrote the file.
+ type: []const u8 = "oauth_device",
+ /// Durable OAuth access token (the `ghu_...` for Copilot; the JWT access
+ /// token for Codex).
+ access_token: ?[]const u8 = null,
+ refresh_token: ?[]const u8 = null,
+ id_token: ?[]const u8 = null,
+ /// Unix-seconds expiry of `access_token` (when known; OAuth `expires_in`
+ /// or a JWT `exp`). Null for tokens with no intrinsic expiry (Copilot's
+ /// durable `ghu_` token).
+ expires_at: ?i64 = null,
+ /// Account id derived from the id_token (Codex `chatgpt-account-id`).
+ account_id: ?[]const u8 = null,
+ /// Result of the secondary exchange (Copilot short-lived API token).
+ exchange: ?ExchangeToken = null,
+
+ pub const ExchangeToken = struct {
+ token: []const u8,
+ /// Unix-seconds expiry of the exchanged token.
+ expires_at: ?i64 = null,
+ /// Dynamic base_url returned by the exchange, if any.
+ base_url: ?[]const u8 = null,
+ };
+};
+
+// ===========================================================================
+// Resolved credential (handed to the provider for one turn)
+// ===========================================================================
+
+/// The request-ready output of resolving an auth session: the secret to place
+/// in the provider's auth header, plus any dynamic base_url and auth-derived
+/// headers. The embedder injects these into the active `ProviderConfig`.
+pub const ResolvedCredential = struct {
+ /// Value for the provider auth header (the bearer / x-api-key value).
+ api_key: []const u8,
+ /// Overrides the provider's configured `base_url` when present (e.g.
+ /// Copilot's `endpoints.api`).
+ base_url_override: ?[]const u8 = null,
+ /// Auth-derived headers (e.g. `chatgpt-account-id`) to merge onto the
+ /// provider's request.
+ extra_headers: []const Header = &.{},
+};
+
+const t = std.testing;
+
+test "AuthConfig: api_key variant" {
+ const a: AuthConfig = .{ .api_key = .{ .key_env_var = "OPENAI_API_KEY" } };
+ try t.expectEqual(AuthType.api_key, @as(AuthType, a));
+ try t.expectEqualStrings("OPENAI_API_KEY", a.api_key.key_env_var.?);
+}
+
+test "AuthConfig: oauth_device defaults" {
+ const a: AuthConfig = .{ .oauth_device = .{
+ .client_id = "Iv1.x",
+ .device_code_url = "https://github.com/login/device/code",
+ .token_url = "https://github.com/login/oauth/access_token",
+ } };
+ try t.expectEqual(DeviceDialect.token, a.oauth_device.dialect);
+ try t.expectEqual(TokenRequestFormat.form, a.oauth_device.token_request_format);
+ try t.expect(a.oauth_device.exchange == null);
+}
+
+test "TokenSet: defaults" {
+ const ts: TokenSet = .{};
+ try t.expectEqualStrings("oauth_device", ts.type);
+ try t.expect(ts.access_token == null);
+ try t.expect(ts.exchange == null);
+}
diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig
index 7e1e8e1..09aaec8 100644
--- a/libpanto/src/config.zig
+++ b/libpanto/src/config.zig
@@ -26,6 +26,16 @@ pub const APIStyle = enum {
anthropic_messages,
};
+/// A single HTTP request header (name/value). Used for provider
+/// `extra_headers` — caller-supplied headers merged onto a provider's
+/// built-in request headers (e.g. GitHub Copilot's editor-identity headers,
+/// or auth-derived headers from an OAuth exchange). Borrowed slices; valid
+/// as long as the owning config is.
+pub const Header = struct {
+ name: []const u8,
+ value: []const u8,
+};
+
/// Reasoning intensity hint sent to providers that support it.
///
/// `.default` omits the field entirely so the provider's own default applies.
@@ -70,6 +80,11 @@ pub const OpenAIChatConfig = struct {
model: []const u8,
reasoning: ReasoningEffort = .default,
max_tokens: u32 = 64_000,
+ /// Caller-supplied request headers merged onto the built-in ones
+ /// (content-type/accept/authorization). Used for provider identity
+ /// headers and auth-exchange-derived headers. Empty by default.
+ /// Borrowed; valid as long as this config is.
+ extra_headers: []const Header = &.{},
};
pub const AnthropicMessagesConfig = struct {
@@ -113,6 +128,9 @@ pub const AnthropicMessagesConfig = struct {
/// history so prefixes are never reused. There the 1.25x write is pure
/// overhead with no read to amortize it.
prompt_cache: bool = true,
+ /// Caller-supplied request headers merged onto the built-in ones. See
+ /// `OpenAIChatConfig.extra_headers`. Empty by default.
+ extra_headers: []const Header = &.{},
};
/// Per-provider transport/auth/model configuration. Tagged by `APIStyle`.
diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig
index 277b4ea..2ee44dd 100644
--- a/libpanto/src/public.zig
+++ b/libpanto/src/public.zig
@@ -29,6 +29,7 @@
const std = @import("std");
const config_mod = @import("config.zig");
+const auth_mod = @import("auth.zig");
const conversation_mod = @import("conversation.zig");
const agent_mod = @import("agent.zig");
const stream_mod = @import("stream.zig");
@@ -69,6 +70,21 @@ pub const Effort = config_mod.Effort;
pub const CompactionConfig = config_mod.CompactionConfig;
pub const RetryConfig = config_mod.RetryConfig;
pub const WireIdentity = config_mod.WireIdentity;
+pub const Header = config_mod.Header;
+
+// ===========================================================================
+// Auth (data + flows, aliased)
+// ===========================================================================
+
+pub const AuthConfig = auth_mod.AuthConfig;
+pub const AuthType = auth_mod.AuthType;
+pub const ApiKeyAuth = auth_mod.ApiKeyAuth;
+pub const OAuthDeviceAuth = auth_mod.OAuthDeviceAuth;
+pub const ExchangeConfig = auth_mod.ExchangeConfig;
+pub const DeviceDialect = auth_mod.DeviceDialect;
+pub const TokenRequestFormat = auth_mod.TokenRequestFormat;
+pub const TokenSet = auth_mod.TokenSet;
+pub const ResolvedCredential = auth_mod.ResolvedCredential;
// ===========================================================================
// Conversation construction (data, aliased)
@@ -205,6 +221,7 @@ test {
// Internal modules' tests (not part of the public surface, but their
// tests must still run).
std.testing.refAllDecls(config_mod);
+ std.testing.refAllDecls(auth_mod);
std.testing.refAllDecls(conversation_mod);
std.testing.refAllDecls(agent_mod);
std.testing.refAllDecls(stream_mod);
diff --git a/src/config_file.zig b/src/config_file.zig
index 1858520..19e4bb1 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -20,15 +20,22 @@
//!
//! After merge, the document is resolved into a `Config`:
//!
-//! - Every `providers.<name>` becomes a `Provider`. Its API key is taken
-//! from `api_key` if present, else from the environment variable named
-//! by `api_key_env_var`. A provider whose only key source is an absent
-//! env var is **silently dropped** — this is what lets the base layer
-//! ship default OpenAI/Anthropic providers that simply don't appear when
-//! the user hasn't exported the corresponding key. An `anthropic_messages`
-//! provider may also set `prompt_cache = <bool>` (default true) to control
-//! the advancing `cache_control` breakpoint; it is ignored for
-//! `openai_chat` providers.
+//! - 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.
+//! - 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).
+//! A provider survives config resolution even when its auth is not
+//! currently resolved. An `anthropic_messages` provider may also set
+//! `prompt_cache = <bool>` (default true) to control the advancing
+//! `cache_control` breakpoint; it is ignored for `openai_chat`
+//! providers. `extra_headers` (a table of string headers) rides on the
+//! model request.
//! - `defaults.model` is parsed as `<provider_name>:<model_alias>`.
//! - `tools` / `extensions` allow- and deny-lists are captured verbatim
//! (as glob pattern lists). A pattern appearing in both allow and deny
@@ -60,28 +67,45 @@ pub const APIStyle = panto.APIStyle;
// Resolved config model
// ===========================================================================
-/// One fully-resolved provider. The API key has already been read (from the
-/// inline value or the named env var); a provider that never resolved a key
-/// is absent from `Config.providers` entirely.
+/// 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.<name>` key). This is
/// the name used on the left of a `<provider>:<model>` reference.
name: []const u8,
style: APIStyle,
base_url: []const u8,
- api_key: []const u8,
+ /// The `auth.<name>` session this provider draws its credential from.
+ auth_name: []const u8,
/// 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);
alloc.free(self.base_url);
- alloc.free(self.api_key);
+ alloc.free(self.auth_name);
+ // `extra_headers` lives in the Config auth arena; not freed here.
}
};
+/// 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
+/// 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 `<provider>:<model>` reference. Both halves borrow from the
/// owning `Config` (the `default_model` storage); do not free separately.
pub const ModelRef = struct {
@@ -134,6 +158,17 @@ pub fn buildProviderConfig(
) ResolveError!panto.ProviderConfig {
const prov = cfg.provider(ref.provider) orelse return error.UnknownProvider;
+ // 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 = blk: {
+ const a = cfg.auth(prov.auth_name) orelse break :blk "";
+ break :blk a.resolved_api_key orelse "";
+ };
+
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;
@@ -141,14 +176,15 @@ pub fn buildProviderConfig(
switch (prov.style) {
.openai_chat => return .{ .openai_chat = .{
- .api_key = prov.api_key,
+ .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 = prov.api_key,
+ .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",
@@ -158,6 +194,7 @@ pub fn buildProviderConfig(
.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,
+ .extra_headers = prov.extra_headers,
} },
}
}
@@ -165,6 +202,13 @@ pub fn buildProviderConfig(
pub const Config = struct {
allocator: Allocator,
providers: []Provider,
+ /// Named auth sessions (`auth.<name>`). 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,
@@ -181,14 +225,17 @@ pub const Config = struct {
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 (e.g.
- /// dropped because its env var wasn't set).
+ /// 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;
@@ -196,6 +243,14 @@ pub const Config = struct {
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,
@@ -240,6 +295,13 @@ 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).
+ InvalidAuth,
+ /// A networked provider is missing `auth = "<name>"`.
+ MissingProviderAuth,
+ /// A provider's `auth = "<name>"` names a session with no `[auth.<name>]`.
+ UnknownProviderAuth,
} || Allocator.Error || FileError;
pub const ResolveError = error{
@@ -425,6 +487,26 @@ fn resolve(
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.<name>`), 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);
@@ -435,12 +517,24 @@ fn resolve(
if (providers_tbl.* == .table) {
var it = toml.tableIterator(providers_tbl);
while (it.next()) |entry| {
- const maybe = try resolveProvider(allocator, environ_map, entry.key, entry.value);
- if (maybe) |p| try providers.append(allocator, p);
+ 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| {
+ var found = false;
+ for (auths.items) |a| {
+ if (std.mem.eql(u8, a.name, p.auth_name)) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) return error.UnknownProviderAuth;
+ }
+
// Tool / extension policies.
var tools = try resolvePolicy(allocator, root, "tools");
errdefer tools.deinit(allocator);
@@ -484,6 +578,8 @@ fn resolve(
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| {
@@ -512,6 +608,8 @@ fn resolve(
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,
@@ -522,14 +620,17 @@ fn resolve(
};
}
-/// Resolve one `providers.<name>` entry. Returns null (provider dropped) if
-/// the only key source is an env var that isn't set.
+/// Resolve one `providers.<name>` 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,
- environ_map: *const std.process.Environ.Map,
+ aa: Allocator,
name: []const u8,
val: *const toml.Value,
-) Error!?Provider {
+) Error!Provider {
if (val.* != .table) return error.InvalidProvider;
const style_str = (val.get("style") orelse return error.InvalidProvider).asString() orelse
@@ -539,19 +640,9 @@ fn resolveProvider(
const base_url = (val.get("base_url") orelse return error.InvalidProvider).asString() orelse
return error.InvalidProvider;
- // api_key wins over api_key_env_var.
- const api_key: []const u8 = blk: {
- if (val.get("api_key")) |k| {
- if (k.asString()) |s| break :blk s;
- }
- if (val.get("api_key_env_var")) |ev| {
- if (ev.asString()) |env_name| {
- if (environ_map.get(env_name)) |actual| break :blk actual;
- }
- }
- // No usable key — silently omit this provider.
- return null;
- };
+ // 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;
// anthropic_messages only; absent => library default (true).
const prompt_cache: bool = blk: {
@@ -561,15 +652,139 @@ fn resolveProvider(
break :blk true;
};
+ const extra_headers = try parseHeaders(aa, val.get("extra_headers"));
+
return .{
.name = try allocator.dupe(u8, name),
.style = style,
.base_url = try allocator.dupe(u8, base_url),
- .api_key = try allocator.dupe(u8, api_key),
+ .auth_name = try allocator.dupe(u8, auth_name),
.prompt_cache = prompt_cache,
+ .extra_headers = extra_headers,
};
}
+// ===========================================================================
+// 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).
+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 type_str = (val.get("type") orelse return error.InvalidAuth).asString() orelse
+ 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;
+ };
+ return .{
+ .name = try aa.dupe(u8, name),
+ .config = .{ .api_key = .{ .key = key, .key_env_var = key_env_var } },
+ .resolved_api_key = resolved,
+ };
+ },
+ .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 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"),
+ .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")),
+ };
+ // 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,
+ };
+ },
+ }
+}
+
+/// 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;
+ 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")),
+ };
+}
+
+/// 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);
+}
+
+/// 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;
+ return v.asString();
+}
+
fn resolvePolicy(allocator: Allocator, root: *const toml.Value, key: []const u8) Error!Policy {
var allow: [][]const u8 = &.{};
var deny: [][]const u8 = &.{};
@@ -707,46 +922,209 @@ test "Policy.permits: allow gates everything not matched" {
try testing.expect(!policy.permits("other.read"));
}
-test "resolve: env-backed provider is dropped when env var is absent" {
+/// 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]
+ \\type = "api_key"
+ \\key_env_var = "ANTHROPIC_API_KEY"
+;
+
+test "resolve: provider survives with unresolved auth when env var is absent" {
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();
+ // Clean break: the provider stays selectable even with no key resolved.
+ try testing.expectEqual(@as(usize, 1), cfg.providers.len);
+ 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);
+}
+
+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"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
+ \\auth = "ghost"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ try testing.expectError(error.UnknownProviderAuth, resolve(a, &env, doc.root));
+}
+
+test "resolve: auth literal key wins over key_env_var" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("KEY_FROM_ENV", "env-value");
+
+ const src =
+ \\[providers.openai]
+ \\style = "openai_chat"
+ \\base_url = "https://api.openai.com/v1"
+ \\auth = "k"
+ \\
+ \\[auth.k]
+ \\type = "api_key"
+ \\key = "inline-value"
+ \\key_env_var = "KEY_FROM_ENV"
;
const doc = try parseDoc(a, src);
defer doc.deinit();
var cfg = try resolve(a, &env, doc.root);
defer cfg.deinit();
- try testing.expectEqual(@as(usize, 0), cfg.providers.len);
+ try testing.expectEqualStrings("inline-value", cfg.auth("k").?.resolved_api_key.?);
}
-test "resolve: env-backed provider resolves when env var is set" {
+test "resolve: invalid auth type is an error" {
const a = testing.allocator;
var env = emptyEnv(a);
defer env.deinit();
- try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
+ 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 =
- \\[providers.anthropic]
- \\style = "anthropic_messages"
- \\base_url = "https://api.anthropic.com"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
+ \\[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"
+ \\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"
+ ;
+ 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;
+ try testing.expectEqual(panto.DeviceDialect.token, oauth.dialect);
+ try testing.expectEqualStrings("Iv1.b507a08c87ecfe98", oauth.client_id);
+ try testing.expect(oauth.exchange != null);
+ 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" {
+ 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();
- try testing.expectEqual(@as(usize, 1), cfg.providers.len);
- const p = cfg.provider("anthropic").?;
- try testing.expectEqual(APIStyle.anthropic_messages, p.style);
- try testing.expectEqualStrings("sk-ant-xyz", p.api_key);
+
+ 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: prompt_cache defaults true and is parsed when set" {
@@ -758,13 +1136,17 @@ test "resolve: prompt_cache defaults true and is parsed when set" {
\\[providers.anthropic]
\\style = "anthropic_messages"
\\base_url = "https://api.anthropic.com"
- \\api_key = "sk-ant"
+ \\auth = "k"
\\
\\[providers.anthropic_nocache]
\\style = "anthropic_messages"
\\base_url = "https://api.anthropic.com"
- \\api_key = "sk-ant"
+ \\auth = "k"
\\prompt_cache = false
+ \\
+ \\[auth.k]
+ \\type = "api_key"
+ \\key = "sk-ant"
;
const doc = try parseDoc(a, src);
defer doc.deinit();
@@ -787,8 +1169,12 @@ test "buildProviderConfig: prompt_cache flows from provider into anthropic confi
\\[providers.anthropic]
\\style = "anthropic_messages"
\\base_url = "https://api.anthropic.com"
- \\api_key = "sk-ant"
+ \\auth = "k"
\\prompt_cache = false
+ \\
+ \\[auth.k]
+ \\type = "api_key"
+ \\key = "sk-ant"
;
const doc = try parseDoc(a, src);
defer doc.deinit();
@@ -804,28 +1190,6 @@ test "buildProviderConfig: prompt_cache flows from provider into anthropic confi
try testing.expectEqual(false, pc.anthropic_messages.prompt_cache);
}
-test "resolve: inline api_key wins over env var" {
- const a = testing.allocator;
- var env = emptyEnv(a);
- defer env.deinit();
- try env.put("KEY_FROM_ENV", "env-value");
-
- const src =
- \\[providers.openai]
- \\style = "openai_chat"
- \\base_url = "https://api.openai.com/v1"
- \\api_key = "inline-value"
- \\api_key_env_var = "KEY_FROM_ENV"
- ;
- const doc = try parseDoc(a, src);
- defer doc.deinit();
-
- var cfg = try resolve(a, &env, doc.root);
- defer cfg.deinit();
- const p = cfg.provider("openai").?;
- try testing.expectEqualStrings("inline-value", p.api_key);
-}
-
test "resolve: unknown default-model provider is an error" {
const a = testing.allocator;
var env = emptyEnv(a);
@@ -877,7 +1241,11 @@ test "loadFromPaths: layers merge with tables accumulating and scalars overridin
\\[providers.anthropic]
\\style = "anthropic_messages"
\\base_url = "https://api.anthropic.com"
- \\api_key = "sys-key"
+ \\auth = "anthropic_api"
+ \\
+ \\[auth.anthropic_api]
+ \\type = "api_key"
+ \\key = "sys-key"
\\
\\[tools]
\\allow = ["std.*"]
@@ -894,7 +1262,11 @@ test "loadFromPaths: layers merge with tables accumulating and scalars overridin
\\[providers.openai]
\\style = "openai_chat"
\\base_url = "https://api.openai.com/v1"
- \\api_key = "proj-key"
+ \\auth = "openai_api"
+ \\
+ \\[auth.openai_api]
+ \\type = "api_key"
+ \\key = "proj-key"
});
var env = emptyEnv(a);
@@ -937,7 +1309,11 @@ test "loadFromPaths: local layer overrides project layer" {
\\[providers.openai]
\\style = "openai_chat"
\\base_url = "https://api.openai.com/v1"
- \\api_key = "proj-key"
+ \\auth = "openai_api"
+ \\
+ \\[auth.openai_api]
+ \\type = "api_key"
+ \\key = "proj-key"
});
// Local layer (git-ignored): a developer's more-opinionated override of
@@ -967,13 +1343,7 @@ test "buildProviderConfig: anthropic ref pulls knobs from model defs" {
defer env.deinit();
try env.put("ANTHROPIC_API_KEY", "sk-ant");
- const src =
- \\[providers.anthropic]
- \\style = "anthropic_messages"
- \\base_url = "https://api.anthropic.com"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
- ;
- const doc = try parseDoc(a, src);
+ const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
var cfg = try resolve(a, &env, doc.root);
defer cfg.deinit();
@@ -1014,7 +1384,11 @@ test "buildProviderConfig: missing alias uses the alias verbatim as wire model"
\\[providers.openai]
\\style = "openai_chat"
\\base_url = "https://api.openai.com/v1"
- \\api_key = "sk-test"
+ \\auth = "k"
+ \\
+ \\[auth.k]
+ \\type = "api_key"
+ \\key = "sk-test"
;
const doc = try parseDoc(a, src);
defer doc.deinit();
@@ -1037,13 +1411,7 @@ test "buildProviderConfig: anthropic adaptive thinking threaded from model def"
defer env.deinit();
try env.put("ANTHROPIC_API_KEY", "sk-ant");
- const src =
- \\[providers.anthropic]
- \\style = "anthropic_messages"
- \\base_url = "https://api.anthropic.com"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
- ;
- const doc = try parseDoc(a, src);
+ const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
var cfg = try resolve(a, &env, doc.root);
defer cfg.deinit();
@@ -1076,13 +1444,7 @@ test "buildProviderConfig: anthropic missing alias falls back to struct defaults
defer env.deinit();
try env.put("ANTHROPIC_API_KEY", "sk-ant");
- const src =
- \\[providers.anthropic]
- \\style = "anthropic_messages"
- \\base_url = "https://api.anthropic.com"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
- ;
- const doc = try parseDoc(a, src);
+ const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
var cfg = try resolve(a, &env, doc.root);
defer cfg.deinit();
@@ -1126,7 +1488,11 @@ test "selectModel: default_model wins; single-provider fallback otherwise" {
\\[providers.openai]
\\style = "openai_chat"
\\base_url = "https://api.openai.com/v1"
- \\api_key = "sk"
+ \\auth = "k"
+ \\
+ \\[auth.k]
+ \\type = "api_key"
+ \\key = "sk"
;
const doc = try parseDoc(a, src);
defer doc.deinit();
@@ -1179,11 +1545,7 @@ test "resolve: [compaction] keep_verbatim and model parse" {
defer env.deinit();
try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
- const src =
- \\[providers.anthropic]
- \\style = "anthropic_messages"
- \\base_url = "https://api.anthropic.com"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
+ const src = anthropic_env_src ++
\\
\\[compaction]
\\keep_verbatim = 12345
@@ -1206,13 +1568,7 @@ test "resolve: [compaction] absent leaves defaults null" {
defer env.deinit();
try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
- const src =
- \\[providers.anthropic]
- \\style = "anthropic_messages"
- \\base_url = "https://api.anthropic.com"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
- ;
- const doc = try parseDoc(a, src);
+ const doc = try parseDoc(a, anthropic_env_src);
defer doc.deinit();
var cfg = try resolve(a, &env, doc.root);
@@ -1228,11 +1584,7 @@ test "resolve: [compaction] model naming an unresolved provider errors" {
defer env.deinit();
try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
- const src =
- \\[providers.anthropic]
- \\style = "anthropic_messages"
- \\base_url = "https://api.anthropic.com"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
+ const src = anthropic_env_src ++
\\
\\[compaction]
\\model = "openai:gpt-4o"
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
index 7f01c19..ac34ee0 100644
--- a/src/luarocks_runtime.zig
+++ b/src/luarocks_runtime.zig
@@ -267,9 +267,10 @@ fn stageAgentTree(
}
/// The default base `config.toml`, materialized at `$PANTO_HOME/config.toml`
-/// on first run if absent. It declares OpenAI and Anthropic providers whose
-/// API keys come from the conventional env vars — so a provider simply
-/// doesn't appear unless its key is exported. Edit or override at the user
+/// on first run if absent. It declares OpenAI and Anthropic providers, each
+/// naming an `[auth.<name>]` session whose key comes from the conventional
+/// env var — so a provider's first request fails with a clear auth error
+/// unless its key is exported. Edit or override at the user
/// (`~/.config/panto/config.toml`) or project (`./.panto/config.toml`) layer.
const default_base_config =
\\# panto base config (auto-generated on first run).
@@ -280,19 +281,28 @@ const default_base_config =
\\# Tables merge across layers; scalars and arrays from a higher layer
\\# replace the lower one wholesale.
\\#
- \\# A provider whose API key (or its api_key_env_var) is missing at
- \\# runtime is silently dropped — so these defaults disappear unless
- \\# you've exported the corresponding key.
+ \\# 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.
\\
\\[providers.openai]
\\style = "openai_chat"
\\base_url = "https://api.openai.com/v1"
- \\api_key_env_var = "OPENAI_API_KEY"
+ \\auth = "openai_api"
+ \\
+ \\[auth.openai_api]
+ \\type = "api_key"
+ \\key_env_var = "OPENAI_API_KEY"
\\
\\[providers.anthropic]
\\style = "anthropic_messages"
\\base_url = "https://api.anthropic.com"
- \\api_key_env_var = "ANTHROPIC_API_KEY"
+ \\auth = "anthropic_api"
+ \\
+ \\[auth.anthropic_api]
+ \\type = "api_key"
+ \\key_env_var = "ANTHROPIC_API_KEY"
\\
\\# Pick the default model with `<provider>:<alias>`. The alias is
\\# resolved against models.toml; an unknown alias is used verbatim as
@@ -301,6 +311,42 @@ const default_base_config =
\\# [defaults]
\\# 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.
+ \\#
+ \\# [providers.copilot]
+ \\# style = "openai_chat"
+ \\# base_url = "https://api.individual.githubcopilot.com"
+ \\# 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"
+ \\# 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"
+ \\# 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"
+ \\
\\# Tool/extension availability (glob patterns). Empty allow = allow all.
\\# [tools]
\\# allow = ["std.*"]