From 9f8719929dbd1326d9b327885c4da319c6d2673c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 15 Jun 2026 17:08:00 -0600 Subject: anthropic credentials in oauth providers (eg copilot) --- libpanto/src/config.zig | 6 +++ libpanto/src/provider_anthropic_messages.zig | 56 ++++++++++++++++++++++++---- src/auth_manager.zig | 52 +++++++++++++++++++++++++- src/config_file.zig | 34 +++++++++++++++-- src/tui_app.zig | 50 ++++++++++++++++++++++--- 5 files changed, 180 insertions(+), 18 deletions(-) diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig index 7c8a495..f2f04e7 100644 --- a/libpanto/src/config.zig +++ b/libpanto/src/config.zig @@ -122,6 +122,11 @@ pub const AnthropicMessagesConfig = struct { /// between tool calls. Ignored when `thinking == .adaptive` (interleaving /// is automatic there) or `.disabled`. thinking_interleaved: bool = false, + /// Use `Authorization: Bearer ...` instead of `x-api-key`. This is set + /// by the embedder from the configured auth family (not guessed from the + /// base URL): standard Anthropic uses `false`, while OAuth-backed + /// Anthropic-compatible providers such as Copilot set `true`. + use_bearer_auth: bool = false, /// Place one `cache_control` breakpoint on the last cacheable block of /// each request, replicating Anthropic's "automatic caching" (a single /// advancing breakpoint) via the broadly-supported per-block marker @@ -344,6 +349,7 @@ test "ProviderConfig - anthropic_messages variant" { try t.expectEqual(APIStyle.anthropic_messages, cfg.style()); try t.expectEqualStrings("2023-06-01", cfg.anthropic_messages.api_version); try t.expectEqual(@as(u32, 64_000), cfg.anthropic_messages.max_tokens); + try t.expectEqual(false, cfg.anthropic_messages.use_bearer_auth); try t.expectEqual(Thinking.disabled, cfg.anthropic_messages.thinking); try t.expectEqual(Effort.medium, cfg.anthropic_messages.effort); try t.expectEqual(@as(?u32, 32_000), cfg.anthropic_messages.thinking_budget_tokens); diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index 95013d1..5721e4a 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -85,16 +85,34 @@ pub const AnthropicMessagesRequest = struct { defer self.allocator.free(body); std.log.debug("anthropic_messages => {s}", .{body}); - // Build headers. The four base headers are always present; the - // interleaved-thinking beta header is added only when the config - // explicitly requests manual extended thinking with interleaving. - // It is intentionally NOT sent for `.adaptive` (interleaving is - // automatic there and the header causes 400s on some backends) or - // `.disabled`. + // Build headers. Standard Anthropic-compatible backends use + // `x-api-key`. OAuth-backed Anthropic-compatible providers (e.g. + // Copilot) opt into `Authorization: Bearer ...` via + // `config.use_bearer_auth`, which is derived from the configured auth + // family rather than guessed from the URL/headers. The four base + // headers are always present; the interleaved-thinking beta header is + // added only when the config explicitly requests manual extended + // thinking with interleaving. It is intentionally NOT sent for + // `.adaptive` (interleaving is automatic there and the header causes + // 400s on some backends) or `.disabled`. + const use_bearer_auth = self.config.use_bearer_auth; + const auth_value = if (use_bearer_auth) + try std.fmt.allocPrint( + self.allocator, + "Bearer {s}", + .{self.config.api_key}, + ) + else + ""; + defer if (use_bearer_auth) self.allocator.free(auth_value); + const auth_header: http.Header = if (use_bearer_auth) + .{ .name = "authorization", .value = auth_value } + else + .{ .name = "x-api-key", .value = self.config.api_key }; var headers_buf: [5]http.Header = .{ .{ .name = "content-type", .value = "application/json" }, .{ .name = "accept", .value = "text/event-stream" }, - .{ .name = "x-api-key", .value = self.config.api_key }, + auth_header, .{ .name = "anthropic-version", .value = self.config.api_version }, undefined, // slot reserved for the optional beta header }; @@ -1226,6 +1244,11 @@ test "two streamed turns persist assistant replies in the conversation" { ); } +/// Helper: whether this config sends Bearer auth instead of `x-api-key`. +fn headerSliceUsesBearerAuth(cfg: *const config_mod.AnthropicMessagesConfig) bool { + return cfg.use_bearer_auth; +} + /// Helper: build the header slice exactly as `open` does, given a config, /// and return whether the interleaved beta header is present. /// This lets us test the header-selection logic without a live HTTP connection. @@ -1234,6 +1257,25 @@ fn headerSliceIncludesInterleaved(cfg: *const config_mod.AnthropicMessagesConfig return send_interleaved; } +test "oauth-backed anthropic auth uses bearer auth" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "https://api.individual.githubcopilot.com", + .model = "claude-sonnet-4-5", + .use_bearer_auth = true, + }; + try testing.expect(headerSliceUsesBearerAuth(&cfg)); +} + +test "plain anthropic auth uses x-api-key" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "https://api.anthropic.com/v1", + .model = "claude-sonnet-4-5", + }; + try testing.expect(!headerSliceUsesBearerAuth(&cfg)); +} + test "interleaved beta header: enabled when thinking=.enabled and interleaved=true" { const cfg: config_mod.AnthropicMessagesConfig = .{ .api_key = "k", diff --git a/src/auth_manager.zig b/src/auth_manager.zig index 5504807..e91339b 100644 --- a/src/auth_manager.zig +++ b/src/auth_manager.zig @@ -166,6 +166,31 @@ pub const AuthManager = struct { } }; +/// When an auth exchange returns only an origin/base host but the provider's +/// configured base_url carries an API path segment (e.g. Anthropic providers +/// configured as `.../v1`), preserve that path on the override. This keeps +/// dynamic-host exchanges from accidentally dropping a required version prefix. +fn mergeBaseUrlOverride( + arena: Allocator, + style: panto.APIStyle, + provider_base_url: []const u8, + override_base_url: ?[]const u8, +) ![]const u8 { + const over = override_base_url orelse return provider_base_url; + if (style != .anthropic_messages) return over; + + const prov_uri = std.Uri.parse(provider_base_url) catch return over; + const over_uri = std.Uri.parse(over) catch return over; + const prov_path = prov_uri.path.percent_encoded; + const over_path = over_uri.path.percent_encoded; + + const prov_has_path = prov_path.len > 0 and !std.mem.eql(u8, prov_path, "/"); + const over_has_path = over_path.len > 0 and !std.mem.eql(u8, over_path, "/"); + if (!prov_has_path or over_has_path) return over; + + return try std.fmt.allocPrint(arena, "{s}{s}", .{ over, prov_path }); +} + /// Patch a resolved credential into the live provider config: set the bearer/ /// key, override base_url when the credential supplies one, and merge the /// provider's static `extra_headers` with the auth-derived ones (into `arena`). @@ -181,7 +206,7 @@ fn patchCredential( @memcpy(merged[0..prov.extra_headers.len], prov.extra_headers); @memcpy(merged[prov.extra_headers.len..], cred.extra_headers); - const base_url = cred.base_url_override orelse prov.base_url; + const base_url = try mergeBaseUrlOverride(arena, prov.style, prov.base_url, cred.base_url_override); switch (live.provider) { .openai_chat => |*c| { c.api_key = cred.api_key; @@ -239,6 +264,31 @@ test "patchCredential: injects key, base_url override, merged headers" { try t.expectEqualStrings("chatgpt-account-id", live.provider.openai_chat.extra_headers[1].name); } +test "patchCredential: anthropic preserves provider path on base_url override" { + var aa = std.heap.ArenaAllocator.init(t.allocator); + defer aa.deinit(); + const arena = aa.allocator(); + + const prov: config_file.Provider = .{ + .name = "copilot-anthropic", + .style = .anthropic_messages, + .base_url = "https://api.individual.githubcopilot.com/v1", + .auth_name = "github_copilot", + }; + var live: panto.Config = .{ .provider = .{ .anthropic_messages = .{ + .api_key = "", + .base_url = prov.base_url, + .model = "claude-sonnet-4-5", + } } }; + const cred: panto.ResolvedCredential = .{ + .api_key = "exchanged-token", + .base_url_override = "https://api.individual.githubcopilot.com", + }; + try patchCredential(&live, &prov, cred, arena); + + try t.expectEqualStrings("https://api.individual.githubcopilot.com/v1", live.provider.anthropic_messages.base_url); +} + test "resolveInto: api_key auth is a no-op" { const a = t.allocator; var env = std.process.Environ.Map.init(a); diff --git a/src/config_file.zig b/src/config_file.zig index a70a832..6d4b705 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -178,16 +178,16 @@ pub fn buildProviderConfig( ) ResolveError!panto.ProviderConfig { const prov = cfg.provider(ref.provider) orelse return error.UnknownProvider; + const auth_cfg = cfg.auth(prov.auth_name) orelse unreachable; + // 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 api_key: []const u8 = auth_cfg.resolved_api_key orelse ""; + const anthropic_use_bearer_auth = auth_cfg.config == .oauth_device; const def_opt = defs.get(ref.provider, ref.model); const wire_model: []const u8 = if (def_opt) |d| d.model else ref.model; @@ -214,6 +214,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, + .use_bearer_auth = anthropic_use_bearer_auth, .extra_headers = prov.extra_headers, } }, .openai_responses => return .{ .openai_responses = .{ @@ -1362,6 +1363,31 @@ test "resolve: prompt_cache defaults true and is parsed when set" { try testing.expectEqual(false, cfg.provider("anthropic_nocache").?.prompt_cache); } +test "buildProviderConfig: anthropic oauth_device uses bearer auth" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + const src = + \\[providers.copilot_anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.individual.githubcopilot.com" + \\auth = "github_copilot" + \\ + \\[auth.github_copilot] + \\type = "oauth_device" + \\client_id = "Iv1.x" + \\device_code_url = "https://github.com/login/device/code" + \\token_url = "https://github.com/login/oauth/access_token" + ; + var cfg = try loadFromString(a, &env, src); + defer cfg.deinit(); + var defs = models_toml.ModelRegistry.init(a); + defer defs.deinit(); + const pc = try buildProviderConfig(&cfg, &defs, .{ .provider = "copilot_anthropic", .model = "claude-sonnet-4-5" }); + try testing.expectEqual(APIStyle.anthropic_messages, pc.style()); + try testing.expect(pc.anthropic_messages.use_bearer_auth); +} + test "buildProviderConfig: prompt_cache flows from provider into anthropic config" { const a = testing.allocator; var env = emptyEnv(a); diff --git a/src/tui_app.zig b/src/tui_app.zig index 69eb8fa..f9c58fe 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -1008,6 +1008,17 @@ pub const App = struct { /// `ModelRegistry`, and `panto.Agent` (all owned by `main`). The model labels /// and provider/model wire strings the rebuilt config borrows therefore stay /// valid for the whole session. +const ModelPickerRow = struct { + def_index: usize, + item: SelectorItem, +}; + +fn modelPickerRowLessThan(_: void, a: ModelPickerRow, b: ModelPickerRow) bool { + const by_label = std.mem.order(u8, a.item.label, b.item.label); + if (by_label != .eq) return by_label == .lt; + return std.mem.lessThan(u8, a.item.detail, b.item.detail); +} + pub const SelectorController = struct { alloc: std.mem.Allocator, app: *App, @@ -1024,6 +1035,8 @@ pub const SelectorController = struct { /// Built selector item list for the model picker (label = "provider:alias", /// detail = " "). Owned: labels/details are allocated here. model_items: []SelectorItem, + /// Parallel to `model_items`: source registry index for each SORTED row. + model_entry_indices: []usize = &.{}, /// Backing storage for the model item label/detail strings. model_strings: std.ArrayList([]u8) = .empty, /// Reasoning picker items for the CURRENTLY OPEN reasoning picker, rebuilt @@ -1089,6 +1102,7 @@ pub const SelectorController = struct { for (self.model_strings.items) |s| self.alloc.free(s); self.model_strings.deinit(self.alloc); self.alloc.free(self.model_items); + self.alloc.free(self.model_entry_indices); self.alloc.free(self.reasoning_items); self.alloc.free(self.model_label); self.alloc.destroy(self); @@ -1097,16 +1111,22 @@ pub const SelectorController = struct { /// Build the model picker items from every `models.toml` definition. fn buildModelItems(self: *SelectorController) !void { const a = self.alloc; - var items: std.ArrayList(SelectorItem) = .empty; - defer items.deinit(a); - for (self.defs.entries.items) |d| { + var rows: std.ArrayList(ModelPickerRow) = .empty; + defer rows.deinit(a); + for (self.defs.entries.items, 0..) |d, def_index| { const label = try std.fmt.allocPrint(a, "{s}:{s}", .{ d.provider, d.alias }); try self.model_strings.append(a, label); const detail = try formatModelDetail(a, d); try self.model_strings.append(a, detail); - try items.append(a, .{ .label = label, .detail = detail }); + try rows.append(a, .{ .def_index = def_index, .item = .{ .label = label, .detail = detail } }); + } + std.mem.sort(ModelPickerRow, rows.items, {}, modelPickerRowLessThan); + self.model_items = try a.alloc(SelectorItem, rows.items.len); + self.model_entry_indices = try a.alloc(usize, rows.items.len); + for (rows.items, 0..) |row, i| { + self.model_items[i] = row.item; + self.model_entry_indices[i] = row.def_index; } - self.model_items = try items.toOwnedSlice(a); } /// Open the model picker, preselecting the current model. @@ -1191,7 +1211,8 @@ pub const SelectorController = struct { } fn applyModel(self: *SelectorController, index: usize) !void { - const d = self.defs.entries.items[index]; + if (index >= self.model_entry_indices.len) return; + const d = self.defs.entries.items[self.model_entry_indices[index]]; const ref: config_file.ModelRef = .{ .provider = d.provider, .model = d.alias }; const new_provider = config_file.buildProviderConfig(self.file_cfg, self.defs, ref) catch |err| { try self.statusf("[model switch failed: {s}]", .{@errorName(err)}); @@ -2314,6 +2335,23 @@ test "formatModelDetail: shows wire model + non-default knobs" { } } +test "model picker rows sort by provider:alias and keep mapping" { + var rows = [_]ModelPickerRow{ + .{ .def_index = 2, .item = .{ .label = "openai:gpt-4o", .detail = "gpt-4o" } }, + .{ .def_index = 0, .item = .{ .label = "anthropic:sonnet", .detail = "claude-sonnet-4" } }, + .{ .def_index = 1, .item = .{ .label = "anthropic:haiku", .detail = "claude-haiku-4" } }, + }; + + std.mem.sort(ModelPickerRow, rows[0..], {}, modelPickerRowLessThan); + + try testing.expectEqualStrings("anthropic:haiku", rows[0].item.label); + try testing.expectEqual(@as(usize, 1), rows[0].def_index); + try testing.expectEqualStrings("anthropic:sonnet", rows[1].item.label); + try testing.expectEqual(@as(usize, 0), rows[1].def_index); + try testing.expectEqualStrings("openai:gpt-4o", rows[2].item.label); + try testing.expectEqual(@as(usize, 2), rows[2].def_index); +} + test "toggleToolCollapse flips every tool component globally" { const alloc = testing.allocator; const h = try Harness.make(alloc); -- cgit v1.3