From 7343898524f9f692620f4cb276f56cfde30f6269 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 12 Jun 2026 10:38:28 -0600 Subject: fuzzy selectors for models and reasoning levels --- src/tui_selectors.zig | 279 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 src/tui_selectors.zig (limited to 'src/tui_selectors.zig') diff --git a/src/tui_selectors.zig b/src/tui_selectors.zig new file mode 100644 index 0000000..a37acb8 --- /dev/null +++ b/src/tui_selectors.zig @@ -0,0 +1,279 @@ +//! Runtime model / reasoning selectors for the TUI. +//! +//! These pickers let the user switch the active `provider:model` and the +//! reasoning ("effort") level for the LIVE SESSION only — nothing is written +//! back to `config.toml`. A pick rebuilds a `panto.ProviderConfig`, stamps it +//! into an App-owned `panto.Config`, and hands the new config to the agent via +//! `Agent.setConfig` (which takes effect at the start of the next turn). +//! +//! ## Provider-specific reasoning +//! +//! The two providers express reasoning on DIFFERENT scales, and we do not try +//! to flatten them into one enum (that loses Anthropic's `xhigh`/`max`): +//! +//! - OpenAI (`openai_chat`): a single `reasoning` enum +//! (off / minimal / low / medium / high). +//! - Anthropic (`anthropic_messages`): `thinking` is `.adaptive` with an +//! `effort` level (low / medium / high / xhigh / max), or `off` which maps +//! to `thinking = .disabled`. +//! +//! The reasoning picker is therefore built from the ACTIVE provider's option +//! list (`ReasoningOption.forStyle`), so every level the provider supports is +//! reachable. Each option carries a closure-free `apply` that mutates the +//! provider config in place. +//! +//! ## Adaptive-thinking fallback (anthropic) +//! +//! Older Anthropic models reject adaptive thinking with an HTTP 400. Rather +//! than surface that error, the turn driver SILENTLY rewrites the config: +//! `thinking = .enabled` with `thinking_budget_tokens` set to a fraction of +//! the model's `max_tokens` (scaled by the chosen effort), then retries the +//! turn once. See `adaptiveFallback`. + +const std = @import("std"); +const panto = @import("panto"); + +pub const APIStyle = panto.APIStyle; +pub const ReasoningEffort = panto.ReasoningEffort; +pub const Thinking = panto.Thinking; +pub const Effort = panto.Effort; +pub const ProviderConfig = panto.ProviderConfig; + +/// One reasoning level for ONE provider style. The picker renders `label`; on +/// accept, `apply` writes the level into a provider config of the matching +/// style. The two payload variants never mix (an openai option is only applied +/// to an openai config, etc.). +pub const ReasoningOption = struct { + label: []const u8, + detail: []const u8, + value: Value, + + pub const Value = union(APIStyle) { + /// OpenAI `reasoning` enum value. + openai_chat: ReasoningEffort, + /// Anthropic thinking selection: either disabled, or adaptive at an + /// effort level. + anthropic_messages: Anthropic, + }; + + pub const Anthropic = union(enum) { + /// `thinking = .disabled`. + off, + /// `thinking = .adaptive` at this effort. + adaptive: Effort, + }; + + /// The option list for OpenAI-style providers, in display order. + pub const openai = [_]ReasoningOption{ + .{ .label = "off", .detail = "no reasoning", .value = .{ .openai_chat = .off } }, + .{ .label = "minimal", .detail = "least reasoning", .value = .{ .openai_chat = .minimal } }, + .{ .label = "low", .detail = "low effort", .value = .{ .openai_chat = .low } }, + .{ .label = "medium", .detail = "balanced", .value = .{ .openai_chat = .medium } }, + .{ .label = "high", .detail = "most reasoning", .value = .{ .openai_chat = .high } }, + }; + + /// The option list for Anthropic-style providers, in display order. + pub const anthropic = [_]ReasoningOption{ + .{ .label = "off", .detail = "no extended thinking", .value = .{ .anthropic_messages = .off } }, + .{ .label = "low", .detail = "adaptive: low effort", .value = .{ .anthropic_messages = .{ .adaptive = .low } } }, + .{ .label = "medium", .detail = "adaptive: balanced", .value = .{ .anthropic_messages = .{ .adaptive = .medium } } }, + .{ .label = "high", .detail = "adaptive: high effort", .value = .{ .anthropic_messages = .{ .adaptive = .high } } }, + .{ .label = "xhigh", .detail = "adaptive: extra-high effort", .value = .{ .anthropic_messages = .{ .adaptive = .xhigh } } }, + .{ .label = "max", .detail = "adaptive: maximum effort", .value = .{ .anthropic_messages = .{ .adaptive = .max } } }, + }; + + /// The reasoning options offered for a provider config's API style. + pub fn forStyle(style: APIStyle) []const ReasoningOption { + return switch (style) { + .openai_chat => &openai, + .anthropic_messages => &anthropic, + }; + } + + /// Write this option into a provider config (must be the same style). + pub fn apply(self: ReasoningOption, cfg: *ProviderConfig) void { + switch (self.value) { + .openai_chat => |r| switch (cfg.*) { + .openai_chat => |*c| c.reasoning = r, + .anthropic_messages => {}, + }, + .anthropic_messages => |sel| switch (cfg.*) { + .anthropic_messages => |*c| switch (sel) { + .off => c.thinking = .disabled, + .adaptive => |e| { + c.thinking = .adaptive; + c.effort = e; + }, + }, + .openai_chat => {}, + }, + } + } + + /// True when this option matches what `cfg` currently holds (used to + /// preselect the picker on the active value). + pub fn matches(self: ReasoningOption, cfg: ProviderConfig) bool { + switch (self.value) { + .openai_chat => |r| return cfg == .openai_chat and cfg.openai_chat.reasoning == r, + .anthropic_messages => |sel| { + if (cfg != .anthropic_messages) return false; + const c = cfg.anthropic_messages; + return switch (sel) { + .off => c.thinking == .disabled, + .adaptive => |e| c.thinking == .adaptive and c.effort == e, + }; + }, + } + } + + /// The label of the option currently active in `cfg` (for the footer / + /// preselect). Falls back to "" if nothing matches. + pub fn activeLabel(cfg: ProviderConfig) []const u8 { + for (forStyle(cfg.style())) |opt| { + if (opt.matches(cfg)) return opt.label; + } + return ""; + } +}; + +/// Fraction of `max_tokens` to budget for manual ("enabled") thinking in the +/// adaptive-unsupported fallback. Scales with the adaptive effort level. +fn budgetFraction(effort: Effort) f32 { + return switch (effort) { + .low => 0.25, + .medium => 0.5, + .high => 0.75, + .xhigh => 0.9, + .max => 0.95, + }; +} + +/// Rewrite an anthropic config that uses adaptive thinking into the manual +/// ("enabled") form with a token budget derived from the current adaptive +/// effort, for models that reject adaptive thinking. Returns true if a +/// rewrite was applied (i.e. the config was anthropic + adaptive); false +/// otherwise (nothing to fall back from). +pub fn adaptiveFallback(cfg: *ProviderConfig) bool { + switch (cfg.*) { + .anthropic_messages => |*c| { + if (c.thinking != .adaptive) return false; + c.thinking = .enabled; + const frac = budgetFraction(c.effort); + const max = c.max_tokens; + const raw = @as(f32, @floatFromInt(max)) * frac; + // Anthropic's floor is 1024 thinking tokens; cap at max_tokens - 1. + var budget: u32 = @intFromFloat(@max(raw, 1024.0)); + if (budget >= max) budget = if (max > 1) max - 1 else 1; + c.thinking_budget_tokens = budget; + return true; + }, + .openai_chat => return false, + } +} + +/// The wire model id carried by a provider config (the `model` field of either +/// variant). +pub fn wireModel(cfg: ProviderConfig) []const u8 { + return switch (cfg) { + inline else => |c| c.model, + }; +} + +// =========================================================================== +// Tests +// =========================================================================== + +const testing = std.testing; + +fn anthropicCfg() ProviderConfig { + return .{ .anthropic_messages = .{ + .api_key = "k", + .base_url = "u", + .model = "claude", + .max_tokens = 64_000, + .thinking = .disabled, + .effort = .medium, + } }; +} + +fn openaiCfg() ProviderConfig { + return .{ .openai_chat = .{ + .api_key = "k", + .base_url = "u", + .model = "gpt", + .reasoning = .medium, + .max_tokens = 64_000, + } }; +} + +test "forStyle yields the full provider-specific option list" { + try testing.expectEqual(@as(usize, 5), ReasoningOption.forStyle(.openai_chat).len); + // Anthropic exposes off + 5 adaptive efforts (incl. xhigh/max). + try testing.expectEqual(@as(usize, 6), ReasoningOption.forStyle(.anthropic_messages).len); +} + +test "apply: openai option writes the reasoning enum" { + var c = openaiCfg(); + for (ReasoningOption.openai) |opt| { + if (std.mem.eql(u8, opt.label, "high")) opt.apply(&c); + } + try testing.expectEqual(ReasoningEffort.high, c.openai_chat.reasoning); +} + +test "apply: anthropic options reach xhigh and max" { + var c = anthropicCfg(); + for (ReasoningOption.anthropic) |opt| { + if (std.mem.eql(u8, opt.label, "max")) opt.apply(&c); + } + try testing.expectEqual(Thinking.adaptive, c.anthropic_messages.thinking); + try testing.expectEqual(Effort.max, c.anthropic_messages.effort); + + for (ReasoningOption.anthropic) |opt| { + if (std.mem.eql(u8, opt.label, "off")) opt.apply(&c); + } + try testing.expectEqual(Thinking.disabled, c.anthropic_messages.thinking); +} + +test "matches / activeLabel preselect the live value" { + var c = anthropicCfg(); + for (ReasoningOption.anthropic) |opt| { + if (std.mem.eql(u8, opt.label, "xhigh")) opt.apply(&c); + } + try testing.expectEqualStrings("xhigh", ReasoningOption.activeLabel(c)); + + var o = openaiCfg(); + for (ReasoningOption.openai) |opt| { + if (std.mem.eql(u8, opt.label, "low")) opt.apply(&o); + } + try testing.expectEqualStrings("low", ReasoningOption.activeLabel(o)); +} + +test "adaptiveFallback rewrites adaptive -> enabled with a budget" { + var c = anthropicCfg(); + for (ReasoningOption.anthropic) |opt| { + if (std.mem.eql(u8, opt.label, "high")) opt.apply(&c); + } + try testing.expect(adaptiveFallback(&c)); + try testing.expectEqual(Thinking.enabled, c.anthropic_messages.thinking); + // 0.75 * 64000 = 48000. + try testing.expectEqual(@as(?u32, 48_000), c.anthropic_messages.thinking_budget_tokens); + // A second call is a no-op (no longer adaptive). + try testing.expect(!adaptiveFallback(&c)); +} + +test "adaptiveFallback uses effort-scaled fractions (max => 0.95)" { + var c = anthropicCfg(); + for (ReasoningOption.anthropic) |opt| { + if (std.mem.eql(u8, opt.label, "max")) opt.apply(&c); + } + try testing.expect(adaptiveFallback(&c)); + // 0.95 * 64000 = 60800. + try testing.expectEqual(@as(?u32, 60_800), c.anthropic_messages.thinking_budget_tokens); +} + +test "adaptiveFallback no-ops for openai and disabled thinking" { + var oc = openaiCfg(); + try testing.expect(!adaptiveFallback(&oc)); + var ac = anthropicCfg(); // disabled + try testing.expect(!adaptiveFallback(&ac)); +} -- cgit v1.3