diff options
| -rw-r--r-- | libpanto/src/auth.zig | 180 | ||||
| -rw-r--r-- | libpanto/src/config.zig | 18 | ||||
| -rw-r--r-- | libpanto/src/public.zig | 17 | ||||
| -rw-r--r-- | src/config_file.zig | 590 | ||||
| -rw-r--r-- | src/luarocks_runtime.zig | 62 |
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.*"] |
