summaryrefslogtreecommitdiff
path: root/src/tui_selectors.zig
blob: 44246951ae20d055674761ca42182309e25e3509 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
//! 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,
        };
    }

    /// Look up an option by label (case-insensitive) among the levels the
    /// given style supports. Null when the style has no such level (e.g.
    /// "xhigh" on an OpenAI provider).
    pub fn byLabel(style: APIStyle, label: []const u8) ?ReasoningOption {
        for (forStyle(style)) |opt| {
            if (std.ascii.eqlIgnoreCase(opt.label, label)) return opt;
        }
        return null;
    }

    /// 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));
}