summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--libpanto/src/config.zig6
-rw-r--r--libpanto/src/provider_anthropic_messages.zig56
-rw-r--r--src/auth_manager.zig52
-rw-r--r--src/config_file.zig34
-rw-r--r--src/tui_app.zig50
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 = "<wire> <knobs>"). 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);