返回 CodeWhale
reasoning_preference.rs
根目录 / crates / tui / src / reasoning_preference.rs
1 //! Reasoning preferences and effective tiers (#5316).
2 //!
3 //! Separated from TUI app state so engine, client routing, settings, and
4 //! tools can resolve and normalize reasoning effort without depending on
5 //! presentation types.
6
7 use crate::config::ApiProvider;
8 use crate::work_graph::ReasoningEffortTier;
9
10 /// Reasoning-effort tier, mirrored across DeepSeek and Codex effort pickers.
11 ///
12 /// The config file accepts every supported string value for forward-compat with
13 /// providers that expose the full spectrum; DeepSeek currently collapses
14 /// `Low`/`Medium` → `high`. OpenAI Codex normalizes inherited DeepSeek-only
15 /// `Off` to `Low` and keeps `XHigh`, `Max`, and `Ultra` distinct at the
16 /// provider boundary. The default keyboard cycler walks the three DeepSeek-distinct
17 /// tiers: `Off` → `High` → `Max` → `Off`; provider-aware callers should use
18 /// [`ReasoningEffort::cycle_next_in`] with the route's effort list. Auto
19 /// routing has no concrete provider yet, so
20 /// [`ReasoningEffort::cycle_next_for_auto_model`] retains the full
21 /// provider-neutral preference vocabulary until dispatch.
22 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
23 pub enum ReasoningEffort {
24 Off,
25 Minimal,
26 Low,
27 Medium,
28 High,
29 XHigh,
30 Ultra,
31 Auto,
32 #[default]
33 Max,
34 }
35
36 /// Provider-effective reasoning state used by durable receipts and visible
37 /// requested-to-effective labels.
38 ///
39 /// Some routes, notably first-party GLM-5-Turbo, support a thinking toggle but
40 /// publish no effort tiers. Keeping that state distinct prevents a requested
41 /// `max` from being displayed or persisted as an effective `max` claim.
42 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
43 pub enum EffectiveReasoningEffort {
44 Tier(ReasoningEffort),
45 ThinkingEnabledGranularityUnavailable,
46 Unavailable,
47 }
48
49 impl EffectiveReasoningEffort {
50 /// Reconstruct a safe request tier for cache replay and inspection.
51 ///
52 /// Routes with an enabled-but-untiered receipt collapse every non-Off
53 /// request to the same wire toggle, so High is the canonical value that
54 /// keeps reasoning enabled without claiming a granular effective tier.
55 #[must_use]
56 pub const fn request_tier_for_replay(self) -> Option<ReasoningEffort> {
57 match self {
58 Self::Tier(tier) => Some(tier),
59 Self::ThinkingEnabledGranularityUnavailable => Some(ReasoningEffort::High),
60 Self::Unavailable => None,
61 }
62 }
63 }
64
65 impl From<EffectiveReasoningEffort> for ReasoningEffortTier {
66 fn from(value: EffectiveReasoningEffort) -> Self {
67 match value {
68 EffectiveReasoningEffort::Tier(tier) => tier.into(),
69 EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable => {
70 Self::ThinkingEnabledGranularityUnavailable
71 }
72 EffectiveReasoningEffort::Unavailable => Self::Unavailable,
73 }
74 }
75 }
76
77 impl From<ReasoningEffortTier> for EffectiveReasoningEffort {
78 fn from(value: ReasoningEffortTier) -> Self {
79 match value {
80 ReasoningEffortTier::Off => Self::Tier(ReasoningEffort::Off),
81 ReasoningEffortTier::Minimal => Self::Tier(ReasoningEffort::Minimal),
82 ReasoningEffortTier::Low => Self::Tier(ReasoningEffort::Low),
83 ReasoningEffortTier::Medium => Self::Tier(ReasoningEffort::Medium),
84 ReasoningEffortTier::High => Self::Tier(ReasoningEffort::High),
85 ReasoningEffortTier::XHigh => Self::Tier(ReasoningEffort::XHigh),
86 ReasoningEffortTier::Ultra => Self::Tier(ReasoningEffort::Ultra),
87 ReasoningEffortTier::Auto => Self::Tier(ReasoningEffort::Auto),
88 ReasoningEffortTier::Max => Self::Tier(ReasoningEffort::Max),
89 ReasoningEffortTier::ThinkingEnabledGranularityUnavailable => {
90 Self::ThinkingEnabledGranularityUnavailable
91 }
92 ReasoningEffortTier::Unavailable => Self::Unavailable,
93 }
94 }
95 }
96
97 impl From<ReasoningEffort> for ReasoningEffortTier {
98 fn from(value: ReasoningEffort) -> Self {
99 match value {
100 ReasoningEffort::Off => Self::Off,
101 ReasoningEffort::Minimal => Self::Minimal,
102 ReasoningEffort::Low => Self::Low,
103 ReasoningEffort::Medium => Self::Medium,
104 ReasoningEffort::High => Self::High,
105 ReasoningEffort::XHigh => Self::XHigh,
106 ReasoningEffort::Ultra => Self::Ultra,
107 ReasoningEffort::Auto => Self::Auto,
108 ReasoningEffort::Max => Self::Max,
109 }
110 }
111 }
112
113 impl ReasoningEffort {
114 /// Parse an operator-supplied effort value.
115 ///
116 /// This is deliberately the one canonical spelling table for every
117 /// human-facing route. Every canonical setting spelling round-trips:
118 /// `parse_strict(as_setting(effort)) == effort`, including `minimal`.
119 /// Callers that read an old persisted config may use [`Self::from_setting`]
120 /// for its compatibility fallback, but a new CLI, settings, or tool input
121 /// must reject an unknown value instead of quietly turning it into `max`.
122 pub fn parse_strict(value: &str) -> Result<Self, String> {
123 let trimmed = value.trim();
124 match trimmed.to_ascii_lowercase().as_str() {
125 "off" | "disabled" | "none" | "false" => Ok(Self::Off),
126 "minimal" => Ok(Self::Minimal),
127 "low" | "minimum" | "light" => Ok(Self::Low),
128 "medium" | "mid" => Ok(Self::Medium),
129 "high" => Ok(Self::High),
130 "xhigh" => Ok(Self::XHigh),
131 "auto" | "automatic" => Ok(Self::Auto),
132 "ultra" | "ultracode" => Ok(Self::Ultra),
133 "max" | "maximum" => Ok(Self::Max),
134 _ => Err(format!(
135 "Unrecognized reasoning effort {trimmed:?}. Expected one of: auto, off, minimal, low, medium, high, xhigh, ultra, or max."
136 )),
137 }
138 }
139
140 /// Parse a persisted config-file string into an effort tier. Unknown
141 /// legacy values fall back to the default (`Max`) so an old malformed
142 /// settings file never prevents startup. New user input should use
143 /// [`Self::parse_strict`] instead.
144 #[must_use]
145 pub fn from_setting(value: &str) -> Self {
146 Self::parse_strict(value).unwrap_or_default()
147 }
148
149 #[must_use]
150 pub fn from_setting_for_provider(value: &str, provider: ApiProvider) -> Self {
151 Self::from_setting(value).normalize_for_provider(provider)
152 }
153
154 /// Canonical lowercase label used for config storage and UI hints.
155 #[must_use]
156 pub fn as_setting(self) -> &'static str {
157 match self {
158 Self::Off => "off",
159 Self::Minimal => "minimal",
160 Self::Low => "low",
161 Self::Medium => "medium",
162 Self::High => "high",
163 Self::XHigh => "xhigh",
164 Self::Ultra => "ultra",
165 Self::Auto => "auto",
166 Self::Max => "max",
167 }
168 }
169
170 /// Short label for the header chip.
171 #[must_use]
172 pub fn short_label(self) -> &'static str {
173 match self {
174 Self::Off => "off",
175 Self::Minimal => "minimal",
176 Self::Low => "low",
177 Self::Medium => "med",
178 Self::High => "high",
179 Self::XHigh => "xhigh",
180 Self::Ultra => "ultra",
181 Self::Auto => "auto",
182 Self::Max => "max",
183 }
184 }
185
186 /// Provider-facing label for user-visible surfaces.
187 #[must_use]
188 pub fn display_label_for_provider(self, provider: ApiProvider) -> &'static str {
189 match (provider, self.normalize_for_provider(provider)) {
190 (ApiProvider::OpenaiCodex, Self::Minimal) => "low",
191 (ApiProvider::OpenaiCodex, Self::Low) => "low",
192 (ApiProvider::OpenaiCodex, Self::Medium) => "medium",
193 (ApiProvider::OpenaiCodex, Self::High) => "high",
194 // `xhigh`, `max` and `ultra` are three distinct rungs the Codex
195 // roster publishes per model; collapsing them onto "xhigh" dates
196 // from when xhigh was the ceiling and made the top tiers
197 // unreachable and indistinguishable.
198 (ApiProvider::OpenaiCodex, Self::XHigh) => "xhigh",
199 (ApiProvider::OpenaiCodex, Self::Ultra) => "ultra",
200 (ApiProvider::OpenaiCodex, Self::Max) => "max",
201 (ApiProvider::Xai, Self::XHigh) => "xhigh",
202 (_, effort) => effort.short_label(),
203 }
204 }
205
206 /// Value forwarded to the engine/client. `None` means "provider default"
207 /// (for `Off` we still emit `"off"` so the client can inject
208 /// `thinking = {"type": "disabled"}`).
209 #[must_use]
210 pub fn api_value(self) -> Option<&'static str> {
211 Some(self.as_setting())
212 }
213
214 #[must_use]
215 pub fn normalize_for_provider(self, provider: ApiProvider) -> Self {
216 if provider != ApiProvider::OpenaiCodex {
217 return self;
218 }
219 match self {
220 Self::Off => Self::Low,
221 Self::Auto => Self::Medium,
222 other => other,
223 }
224 }
225
226 /// Resolve an effort against the exact provider route that will receive
227 /// the request. Both K3 routes are always-thinking, so `off` becomes the
228 /// lowest supported tier. The Kimi Code membership route otherwise keeps
229 /// its low/high/max mapping; direct Moonshot K3 additionally maps `medium`
230 /// to `high`. First-party DeepSeek routes keep `low` (the wire documents
231 /// low/high/max) while rounding `medium` up to `high`. Models that publish
232 /// a Models.dev `reasoning_options` effort list keep that vocabulary
233 /// instead of the historic Low/Medium collapse. Generic Moonshot and
234 /// every other non-Codex route retain the historic high coercion.
235 /// This intentionally does not change [`Self::normalize_for_provider`],
236 /// whose generic wire semantics are used by older callers that do not yet
237 /// have a route receipt.
238 #[must_use]
239 pub fn normalize_for_route(
240 self,
241 provider: ApiProvider,
242 base_url: &str,
243 wire_model: &str,
244 ) -> Self {
245 let normalized = self.normalize_for_provider(provider);
246 if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
247 return match normalized {
248 Self::Off => Self::Low,
249 other => other,
250 };
251 }
252 if crate::config::is_exact_direct_moonshot_k3_route(provider, base_url, wire_model) {
253 return match normalized {
254 Self::Off => Self::Low,
255 Self::Medium => Self::High,
256 other => other,
257 };
258 }
259 if provider == ApiProvider::OpenaiCodex {
260 return normalized;
261 }
262 // First-party DeepSeek routes document `reasoning_effort` low/high/max
263 // on the wire (no medium), so `low` is a real, cheaper tier there and
264 // must reach the wire as low; `medium` rounds up to high because the
265 // dialect has no such value (#52).
266 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
267 return match normalized {
268 Self::Low => Self::Low,
269 Self::Medium => Self::High,
270 other => other,
271 };
272 }
273 // Ollama's current OpenAI-compatible Chat Completions contract
274 // documents the complete none/low/medium/high/max ladder. Keep every
275 // real tier distinct for normal turns; only Codewhale-only synonyms
276 // are folded onto the nearest documented spelling.
277 if provider == ApiProvider::OllamaCloud {
278 return match normalized {
279 Self::Minimal => Self::Low,
280 Self::XHigh | Self::Ultra => Self::Max,
281 other => other,
282 };
283 }
284 if let Some(values) = Self::catalog_effort_values(provider, wire_model) {
285 return Self::clamp_to_catalog_efforts(normalized, provider, wire_model, &values);
286 }
287 match normalized {
288 Self::Low | Self::Medium => Self::High,
289 other => other,
290 }
291 }
292
293 pub fn catalog_default(provider: ApiProvider, wire_model: &str) -> Option<Self> {
294 let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?;
295 offering.reasoning_options.iter().find_map(|option| {
296 option
297 .get("type")
298 .and_then(|value| value.as_str())
299 .filter(|kind| kind.eq_ignore_ascii_case("effort"))?;
300 option
301 .get("default")
302 .and_then(|value| value.as_str())
303 .and_then(Self::from_catalog_token)
304 })
305 }
306
307 pub fn catalog_effort_values(provider: ApiProvider, wire_model: &str) -> Option<Vec<Self>> {
308 let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?;
309 let mut efforts = Vec::new();
310 for option in &offering.reasoning_options {
311 if !option
312 .get("type")
313 .and_then(|value| value.as_str())
314 .is_some_and(|kind| kind.eq_ignore_ascii_case("effort"))
315 {
316 continue;
317 }
318 let Some(values) = option.get("values").and_then(|value| value.as_array()) else {
319 continue;
320 };
321 for value in values {
322 if let Some(effort) = value.as_str().and_then(Self::from_catalog_token)
323 && !efforts.contains(&effort)
324 {
325 efforts.push(effort);
326 }
327 }
328 }
329 (!efforts.is_empty()).then_some(efforts)
330 }
331
332 pub fn from_catalog_token(raw: &str) -> Option<Self> {
333 match raw.trim().to_ascii_lowercase().as_str() {
334 "off" | "disabled" | "none" | "false" => Some(Self::Off),
335 "minimal" | "minimum" => Some(Self::Minimal),
336 "low" | "light" => Some(Self::Low),
337 "medium" | "mid" => Some(Self::Medium),
338 "high" => Some(Self::High),
339 "xhigh" => Some(Self::XHigh),
340 "ultra" | "ultracode" => Some(Self::Ultra),
341 "max" | "maximum" => Some(Self::Max),
342 "auto" | "automatic" | "adaptive" => Some(Self::Auto),
343 _ => None,
344 }
345 }
346
347 fn clamp_to_catalog_efforts(
348 normalized: Self,
349 provider: ApiProvider,
350 wire_model: &str,
351 values: &[Self],
352 ) -> Self {
353 if matches!(normalized, Self::Auto) || values.contains(&normalized) {
354 return normalized;
355 }
356 let aliased = match normalized {
357 Self::Minimal if values.contains(&Self::Low) => Self::Low,
358 Self::Max | Self::Ultra if values.contains(&Self::XHigh) => Self::XHigh,
359 Self::Off => Self::catalog_default(provider, wire_model).unwrap_or(Self::High),
360 other => other,
361 };
362 if values.contains(&aliased) {
363 aliased
364 } else {
365 Self::catalog_default(provider, wire_model).unwrap_or(Self::High)
366 }
367 }
368
369 #[must_use]
370 pub fn api_value_for_provider(self, provider: ApiProvider) -> Option<&'static str> {
371 if provider != ApiProvider::OpenaiCodex {
372 return self.api_value();
373 }
374 Some(match self.normalize_for_provider(provider) {
375 Self::Minimal => "low",
376 Self::Low => "low",
377 Self::Medium => "medium",
378 Self::High => "high",
379 Self::XHigh => "xhigh",
380 Self::Ultra => "ultra",
381 Self::Max => "max",
382 Self::Off => "low",
383 Self::Auto => "medium",
384 })
385 }
386
387 /// Provider-facing value after exact-route normalization.
388 #[must_use]
389 pub fn api_value_for_route(
390 self,
391 provider: ApiProvider,
392 base_url: &str,
393 wire_model: &str,
394 ) -> Option<&'static str> {
395 self.normalize_for_route(provider, base_url, wire_model)
396 .api_value_for_provider(provider)
397 }
398
399 #[must_use]
400 pub fn as_setting_for_provider(self, provider: ApiProvider) -> &'static str {
401 self.api_value_for_provider(provider)
402 .unwrap_or_else(|| self.as_setting())
403 }
404
405 /// Persist the canonical setting after exact-route normalization.
406 #[must_use]
407 pub fn as_setting_for_route(
408 self,
409 provider: ApiProvider,
410 base_url: &str,
411 wire_model: &str,
412 ) -> &'static str {
413 self.normalize_for_route(provider, base_url, wire_model)
414 .as_setting_for_provider(provider)
415 }
416
417 /// Cycle through the three behaviorally distinct tiers.
418 #[must_use]
419 pub fn cycle_next(self) -> Self {
420 match self {
421 Self::Off => Self::High,
422 Self::Auto => Self::Off,
423 Self::Minimal | Self::Low | Self::Medium | Self::High | Self::XHigh | Self::Ultra => {
424 Self::Max
425 }
426 Self::Max => Self::Off,
427 }
428 }
429
430 /// Advance through an exact-route effort list.
431 ///
432 /// A value that is literally on the ladder advances one rung and wraps.
433 /// An off-ladder value enters at [`Self::nearest_in`]'s rung and advances
434 /// from there, so the cycler only ever moves forward: a persisted `max` on
435 /// an `xhigh` ladder, `ultra` on a `max` ladder, or `minimal` on a ladder
436 /// starting at `off` still walks upward instead of reversing.
437 #[must_use]
438 pub fn cycle_next_in(self, efforts: &[Self]) -> Self {
439 if efforts.is_empty() {
440 return self.cycle_next();
441 }
442 if let Some(index) = self.index_in(efforts) {
443 return efforts[(index + 1) % efforts.len()];
444 }
445 let Some(entry) = self.nearest_in(efforts) else {
446 return self.cycle_next();
447 };
448 let index = entry.index_in(efforts).unwrap_or(0);
449 efforts[(index + 1) % efforts.len()]
450 }
451
452 /// Ladder rank of a concrete tier.
453 ///
454 /// `Auto` is the unresolved sentinel: it names no rung, so it has no rank
455 /// and is never a projection target.
456 const fn rung_rank(self) -> Option<u8> {
457 Some(match self {
458 Self::Off => 0,
459 Self::Minimal => 1,
460 Self::Low => 2,
461 Self::Medium => 3,
462 Self::High => 4,
463 Self::XHigh => 5,
464 Self::Ultra => 6,
465 Self::Max => 7,
466 Self::Auto => return None,
467 })
468 }
469
470 /// Project an effort onto the rungs a route actually offers.
471 ///
472 /// One deterministic rule replaces the hand-written alias buckets: the
473 /// highest allowed rung at or below the current one, or the lowest allowed
474 /// rung when the current value sits below every rung or names no rung at
475 /// all (`Auto`). `None` only when the ladder publishes no rung.
476 #[must_use]
477 pub fn nearest_in(self, efforts: &[Self]) -> Option<Self> {
478 let own_rank = self.rung_rank();
479 let mut nearest_below: Option<Self> = None;
480 let mut lowest: Option<Self> = None;
481 for effort in efforts.iter().copied() {
482 let Some(rank) = effort.rung_rank() else {
483 continue;
484 };
485 if lowest.is_none_or(|current: Self| rank < current.rung_rank().unwrap_or(u8::MAX)) {
486 lowest = Some(effort);
487 }
488 if own_rank.is_some_and(|own| rank <= own)
489 && nearest_below.is_none_or(|current: Self| rank > current.rung_rank().unwrap_or(0))
490 {
491 nearest_below = Some(effort);
492 }
493 }
494 nearest_below.or(lowest)
495 }
496
497 /// Position of a literally listed value on the ladder. Values that are not
498 /// listed are handled by [`Self::nearest_in`], never by an alias table.
499 fn index_in(self, efforts: &[Self]) -> Option<usize> {
500 efforts.iter().position(|&effort| effort == self)
501 }
502
503 /// Cycle the unresolved auto-model preference without applying any
504 /// provider's normalization rules prematurely.
505 #[must_use]
506 pub fn cycle_next_for_auto_model(self) -> Self {
507 match self {
508 Self::Auto => Self::Off,
509 Self::Off => Self::Minimal,
510 Self::Minimal => Self::Low,
511 Self::Low => Self::Medium,
512 Self::Medium => Self::High,
513 Self::High => Self::XHigh,
514 Self::XHigh => Self::Ultra,
515 Self::Ultra => Self::Max,
516 Self::Max => Self::Auto,
517 }
518 }
519 }
520
521 #[cfg(test)]
522 mod tests {
523 use super::ReasoningEffort;
524
525 const CANONICAL: [ReasoningEffort; 9] = [
526 ReasoningEffort::Auto,
527 ReasoningEffort::Off,
528 ReasoningEffort::Minimal,
529 ReasoningEffort::Low,
530 ReasoningEffort::Medium,
531 ReasoningEffort::High,
532 ReasoningEffort::XHigh,
533 ReasoningEffort::Ultra,
534 ReasoningEffort::Max,
535 ];
536
537 #[test]
538 fn reasoning_effort_parse_strict_round_trips_every_canonical_spelling() {
539 for effort in CANONICAL {
540 assert_eq!(
541 ReasoningEffort::parse_strict(effort.as_setting()),
542 Ok(effort),
543 "{} must parse back to itself",
544 effort.as_setting()
545 );
546 assert_eq!(
547 ReasoningEffort::from_setting(effort.as_setting()),
548 effort,
549 "a persisted {} must load as itself",
550 effort.as_setting()
551 );
552 }
553 assert_eq!(
554 ReasoningEffort::parse_strict("minimal"),
555 Ok(ReasoningEffort::Minimal)
556 );
557 assert_eq!(
558 ReasoningEffort::parse_strict("minimum"),
559 Ok(ReasoningEffort::Low)
560 );
561 assert_eq!(
562 ReasoningEffort::parse_strict("light"),
563 Ok(ReasoningEffort::Low)
564 );
565 }
566
567 #[test]
568 fn reasoning_effort_parse_strict_error_lists_the_complete_vocabulary() {
569 let error = ReasoningEffort::parse_strict("xhihg").expect_err("unknown value must fail");
570 for spelling in [
571 "auto", "off", "minimal", "low", "medium", "high", "xhigh", "ultra", "max",
572 ] {
573 assert!(
574 error.contains(spelling),
575 "the error must name {spelling}, got {error:?}"
576 );
577 }
578 }
579
580 #[test]
581 fn reasoning_effort_nearest_in_projects_one_value_per_direction() {
582 use ReasoningEffort::*;
583 let full = [Off, Minimal, Low, Medium, High, XHigh, Ultra, Max];
584 // Literal members project onto themselves.
585 for effort in full {
586 assert_eq!(effort.nearest_in(&full), Some(effort));
587 }
588 // A capped ladder: the highest rung at or below wins, never a rung above.
589 let capped = [Off, Low, Medium, High, XHigh];
590 assert_eq!(Max.nearest_in(&capped), Some(XHigh));
591 assert_eq!(Ultra.nearest_in(&capped), Some(XHigh));
592 assert_eq!(Minimal.nearest_in(&capped), Some(Off));
593 // Below every rung: the lowest allowed rung is the entry point.
594 assert_eq!(Off.nearest_in(&[Low, Medium, High, Max]), Some(Low));
595 // `Auto` names no rung, so it enters at the lowest allowed rung.
596 assert_eq!(Auto.nearest_in(&[Low, Medium, High, Max]), Some(Low));
597 assert_eq!(Auto.nearest_in(&[Off, Low, High, Max]), Some(Off));
598 // A ladder that publishes no rung has nothing to project onto.
599 assert_eq!(Medium.nearest_in(&[Auto]), None);
600 assert_eq!(Medium.nearest_in(&[]), None);
601 }
602
603 #[test]
604 fn reasoning_effort_cycle_next_in_only_moves_forward_on_any_ladder() {
605 use ReasoningEffort::*;
606 // Off-ladder values enter at their nearest rung and advance one rung.
607 let capped = [Off, Low, Medium, High, XHigh];
608 assert_eq!(
609 Max.cycle_next_in(&capped),
610 Off,
611 "xhigh-capped ladder wraps forward"
612 );
613 assert_eq!(Ultra.cycle_next_in(&capped), Off);
614 assert_eq!(Minimal.cycle_next_in(&capped), Low);
615 // A value below every rung enters at the lowest rung.
616 assert_eq!(Off.cycle_next_in(&[Low, Medium, High, Max]), Medium);
617 // `Auto` enters at the ladder's lowest rung.
618 assert_eq!(Auto.cycle_next_in(&[Off, Low, High, Max]), Low);
619 // Literal members advance and wrap without an alias table.
620 assert_eq!(High.cycle_next_in(&capped), XHigh);
621 assert_eq!(XHigh.cycle_next_in(&capped), Off);
622 // An empty ladder falls back to the provider-neutral cycle.
623 assert_eq!(Off.cycle_next_in(&[]), Off.cycle_next());
624 }
625
626 #[test]
627 fn reasoning_effort_cycle_for_auto_model_walks_the_full_vocabulary() {
628 use ReasoningEffort::*;
629 let mut effort = Auto;
630 let mut seen = vec![effort];
631 for _ in 0..8 {
632 effort = effort.cycle_next_for_auto_model();
633 seen.push(effort);
634 }
635 assert_eq!(
636 seen,
637 vec![Auto, Off, Minimal, Low, Medium, High, XHigh, Ultra, Max]
638 );
639 assert_eq!(Max.cycle_next_for_auto_model(), Auto);
640 }
641 }
642
642 lines RUST