//! 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, /// Two payload families, decoupled from `APIStyle`: `openai_chat` carries /// a `reasoning` effort (shared by the `openai_chat` and `openai_responses` /// styles), `anthropic_messages` carries a thinking selection. pub const Value = union(enum) { /// 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) { // The Responses style takes the same reasoning-effort options. .openai_chat, .openai_responses, .openai_codex_responses => &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, .openai_responses => |*c| c.reasoning = r, .openai_codex_responses => |*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, .openai_responses, .openai_codex_responses => {}, }, } } /// 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) or (cfg == .openai_responses and cfg.openai_responses.reasoning == r) or (cfg == .openai_codex_responses and cfg.openai_codex_responses.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, .openai_responses, .openai_codex_responses => 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)); }