locode_provider/effort.rs
1//! The user-facing effort ladder: **our** five tiers, and how they reach a wire.
2//!
3//! Effort naming is not portable. Anthropic exposes `low`/`medium`/`high`/
4//! `xhigh`/`max`; other vendors ship fewer tiers, different names, or none — and
5//! a tier that exists on one model can be rejected by the next. Exposing the
6//! provider's vocabulary directly would make `/effort` mean different things on
7//! different days and break the moment a model is switched.
8//!
9//! So the ladder below is **ours**. It is what the CLI flag, the settings key,
10//! and `/effort` speak; each wire maps it onto whatever that API accepts. The
11//! five rungs deliberately mirror Anthropic's, because Anthropic is what we run
12//! today and a 1:1 mapping is the honest starting point — but the indirection is
13//! the point: a wire with three tiers collapses rungs in *its* mapping rather
14//! than forcing a different menu on the user.
15//!
16//! The rungs were confirmed against the live API rather than read off the
17//! vendored Claude Code snapshot, which predates Fable 5 and lists only
18//! `low|medium|high|max`. Probing `claude-fable-5`, `claude-opus-5` and
19//! `claude-sonnet-5`: `low`, `medium`, `high`, `xhigh`, `max` are all accepted;
20//! `ultra`, `ultrathink` and `extreme` are 400s. **`max` is the top rung.**
21//!
22//! "ultrathink" is not a rung and is deliberately absent: in Claude Code it is a
23//! keyword matched in the *prompt* (`utils/thinking.ts:45`) that bumps effort for
24//! that turn (`utils/effort.ts:321`) — a per-message nudge layered over the
25//! setting, not a level of it.
26
27use crate::request::ReasoningEffort;
28
29/// One rung of the locode effort ladder.
30///
31/// Ordered shallow → deep. This is a closed set on purpose: an arbitrary
32/// vendor string still reaches a wire through
33/// [`ReasoningEffort::Other`](crate::ReasoningEffort::Other), but the *menu* is
34/// a fixed ladder so the same word means the same intent everywhere.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
36pub enum Effort {
37 /// Short, scoped work; latency over depth.
38 Low,
39 /// The balanced middle.
40 Medium,
41 /// The default for intelligence-sensitive work.
42 High,
43 /// Best for coding and agentic runs.
44 XHigh,
45 /// Correctness over cost; can overthink simple tasks.
46 Max,
47}
48
49impl Effort {
50 /// Every rung, shallow → deep (the menu order).
51 pub const ALL: [Effort; 5] = [
52 Effort::Low,
53 Effort::Medium,
54 Effort::High,
55 Effort::XHigh,
56 Effort::Max,
57 ];
58
59 /// The canonical locode name (what the flag, settings, and `/effort` take).
60 #[must_use]
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Effort::Low => "low",
64 Effort::Medium => "medium",
65 Effort::High => "high",
66 Effort::XHigh => "xhigh",
67 Effort::Max => "max",
68 }
69 }
70
71 /// Parse a locode name, case-insensitively. `None` for anything else.
72 #[must_use]
73 pub fn parse(name: &str) -> Option<Effort> {
74 let name = name.trim().to_ascii_lowercase();
75 Effort::ALL.into_iter().find(|e| e.as_str() == name)
76 }
77
78 /// What this rung means, for the menu's second column.
79 #[must_use]
80 pub fn hint(self) -> &'static str {
81 match self {
82 Effort::Low => "fastest; short, scoped tasks",
83 Effort::Medium => "balanced",
84 Effort::High => "the API default",
85 Effort::XHigh => "best for coding and agentic work",
86 Effort::Max => "deepest; correctness over cost",
87 }
88 }
89
90 /// The tier this rung becomes on `api_schema`, for the menu's mapping
91 /// column.
92 ///
93 /// Anthropic is 1:1 today, which is why the rungs are named as they are.
94 /// The function exists so that stops being an assumption the moment a wire
95 /// with a different ladder is added — the menu will show the collapse
96 /// instead of hiding it.
97 #[must_use]
98 pub fn maps_to(self, api_schema: &str) -> &'static str {
99 match api_schema {
100 "anthropic" | "openai-responses" => self.as_str(),
101 _ => "—",
102 }
103 }
104}
105
106impl From<Effort> for ReasoningEffort {
107 fn from(effort: Effort) -> Self {
108 match effort {
109 Effort::Low => ReasoningEffort::Low,
110 Effort::Medium => ReasoningEffort::Medium,
111 Effort::High => ReasoningEffort::High,
112 Effort::XHigh => ReasoningEffort::XHigh,
113 Effort::Max => ReasoningEffort::Max,
114 }
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn names_round_trip_case_insensitively() {
124 for effort in Effort::ALL {
125 assert_eq!(Effort::parse(effort.as_str()), Some(effort));
126 assert_eq!(Effort::parse(&effort.as_str().to_uppercase()), Some(effort));
127 assert_eq!(
128 Effort::parse(&format!(" {} ", effort.as_str())),
129 Some(effort)
130 );
131 }
132 assert_eq!(Effort::parse("ultra"), None, "the ladder is closed");
133 assert_eq!(Effort::parse(""), None);
134 }
135
136 #[test]
137 fn the_ladder_is_ordered_shallow_to_deep() {
138 assert!(Effort::Low < Effort::Medium);
139 assert!(Effort::High < Effort::XHigh);
140 assert!(Effort::XHigh < Effort::Max);
141 }
142
143 /// Anthropic is 1:1 today — the indirection exists for the wire that is not.
144 #[test]
145 fn every_rung_has_a_distinct_neutral_tier() {
146 let tiers: Vec<ReasoningEffort> = Effort::ALL.into_iter().map(Into::into).collect();
147 for (i, a) in tiers.iter().enumerate() {
148 for b in &tiers[i + 1..] {
149 assert_ne!(a, b, "two rungs collapsed onto the same neutral tier");
150 }
151 }
152 }
153
154 #[test]
155 fn anthropic_mapping_is_identity_and_unknown_wires_say_so() {
156 for effort in Effort::ALL {
157 assert_eq!(effort.maps_to("anthropic"), effort.as_str());
158 }
159 assert_eq!(Effort::High.maps_to("mock"), "—");
160 }
161}