diff options
Diffstat (limited to 'src/config_file.zig')
| -rw-r--r-- | src/config_file.zig | 590 |
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" |
