summaryrefslogtreecommitdiff
path: root/src/config_file.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/config_file.zig')
-rw-r--r--src/config_file.zig590
1 files changed, 471 insertions, 119 deletions
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"