| 1 | //! The single source of truth for DeepSeek's reasoning-effort mapping. |
| 2 | //! |
| 3 | //! DeepSeek exposes the same four thinking tiers on both wires it speaks to |
| 4 | //! us, but spells them differently, and until #5055 each spelling lived inline |
| 5 | //! at its call site — so a documented mapping change meant code archaeology |
| 6 | //! across two files instead of one edit here. |
| 7 | //! |
| 8 | //! Source: <https://api-docs.deepseek.com/api/create-chat-completion> for the |
| 9 | //! Chat Completions `reasoning_effort` values, and DeepSeek's Thinking Mode |
| 10 | //! guide <https://api-docs.deepseek.com/guides/reasoning_model> for the |
| 11 | //! requested-to-actual mapping (verified 2026-09-17). |
| 12 | //! |
| 13 | //! What the wires document, and what this table encodes: |
| 14 | //! |
| 15 | //! | Tier | Chat Completions | Responses API | |
| 16 | //! |------|-----------------------------|--------------------| |
| 17 | //! | Off | `thinking: {type: disabled}` | `effort: "none"` | |
| 18 | //! | Low | `reasoning_effort: "low"` | `effort: "low"` | |
| 19 | //! | High | `reasoning_effort: "high"` | `effort: "high"` | |
| 20 | //! | Max | `reasoning_effort: "max"` | `effort: "max"` | |
| 21 | //! |
| 22 | //! DeepSeek's Thinking Mode guide documents the requested-to-actual effort |
| 23 | //! mapping; thinking is enabled by default with an actual effort of `high`: |
| 24 | //! |
| 25 | //! | Requested | Actual | |
| 26 | //! |-----------|--------| |
| 27 | //! | minimal | low | |
| 28 | //! | low | low | |
| 29 | //! | medium | high | |
| 30 | //! | high | high | |
| 31 | //! | xhigh | high | |
| 32 | //! | max | max | |
| 33 | //! | ultra | max | |
| 34 | //! |
| 35 | //! `medium`, `xhigh`, and `ultra` are client-side requests that collapse onto |
| 36 | //! the documented wire tiers — they are not new wire values. When DeepSeek |
| 37 | //! changes the mapping, edit [`DeepseekEffortTier`]'s two wire accessors and |
| 38 | //! [`DEEPSEEK_EFFORT_ALIASES`] below, bump the verified date in this comment, |
| 39 | //! and both call sites move together — that is the whole point of this module. |
| 40 | |
| 41 | /// A documented DeepSeek thinking tier, independent of which wire carries it. |
| 42 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 43 | pub(super) enum DeepseekEffortTier { |
| 44 | /// Thinking disabled. |
| 45 | Off, |
| 46 | /// The cheaper real reasoning tier. |
| 47 | Low, |
| 48 | /// The default reasoning tier; also where an undocumented `medium` lands. |
| 49 | High, |
| 50 | /// The deepest documented tier. |
| 51 | Max, |
| 52 | } |
| 53 | |
| 54 | impl DeepseekEffortTier { |
| 55 | /// Chat Completions `reasoning_effort` value, or `None` for the off tier — |
| 56 | /// which that wire expresses with `thinking: {"type": "disabled"}` rather |
| 57 | /// than an effort string. |
| 58 | pub(super) fn chat_reasoning_effort(self) -> Option<&'static str> { |
| 59 | match self { |
| 60 | Self::Off => None, |
| 61 | Self::Low => Some("low"), |
| 62 | Self::High => Some("high"), |
| 63 | Self::Max => Some("max"), |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// Whether this tier enables thinking on the Chat Completions wire. |
| 68 | pub(super) fn chat_thinking_enabled(self) -> bool { |
| 69 | !matches!(self, Self::Off) |
| 70 | } |
| 71 | |
| 72 | /// Responses API `reasoning.effort` value. Every tier has a documented |
| 73 | /// label here, including off (`"none"`), so the picker's Off entry stays |
| 74 | /// off instead of collapsing into a still-thinking low. |
| 75 | pub(super) fn responses_effort(self) -> &'static str { |
| 76 | match self { |
| 77 | Self::Off => "none", |
| 78 | Self::Low => "low", |
| 79 | Self::High => "high", |
| 80 | Self::Max => "max", |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// Every CodeWhale-side effort spelling that maps onto a documented DeepSeek |
| 86 | /// tier. The empty string is CodeWhale's "unset" effort, which means the |
| 87 | /// default tier. |
| 88 | /// |
| 89 | /// Anything absent from this table is not a DeepSeek tier request: the |
| 90 | /// Responses wire normalizes it to the default tier (it must send *some* |
| 91 | /// documented label), while the Chat wire writes nothing at all rather than |
| 92 | /// guessing a field the user did not ask for. |
| 93 | pub(super) const DEEPSEEK_EFFORT_ALIASES: &[(&str, DeepseekEffortTier)] = &[ |
| 94 | ("off", DeepseekEffortTier::Off), |
| 95 | ("disabled", DeepseekEffortTier::Off), |
| 96 | ("none", DeepseekEffortTier::Off), |
| 97 | ("false", DeepseekEffortTier::Off), |
| 98 | ("minimal", DeepseekEffortTier::Low), |
| 99 | ("low", DeepseekEffortTier::Low), |
| 100 | ("medium", DeepseekEffortTier::High), |
| 101 | ("mid", DeepseekEffortTier::High), |
| 102 | ("high", DeepseekEffortTier::High), |
| 103 | ("", DeepseekEffortTier::High), |
| 104 | ("xhigh", DeepseekEffortTier::High), |
| 105 | ("max", DeepseekEffortTier::Max), |
| 106 | ("maximum", DeepseekEffortTier::Max), |
| 107 | ("highest", DeepseekEffortTier::Max), |
| 108 | ("ultra", DeepseekEffortTier::Max), |
| 109 | ("ultracode", DeepseekEffortTier::Max), |
| 110 | ]; |
| 111 | |
| 112 | /// The tier CodeWhale falls back to when a caller asks for something this |
| 113 | /// table does not name (`auto`, a future provider label, a typo). |
| 114 | pub(super) const DEEPSEEK_DEFAULT_EFFORT_TIER: DeepseekEffortTier = DeepseekEffortTier::High; |
| 115 | |
| 116 | /// Look up a raw effort string. Case- and whitespace-insensitive. |
| 117 | pub(super) fn deepseek_effort_tier(raw: &str) -> Option<DeepseekEffortTier> { |
| 118 | let normalized = raw.trim().to_ascii_lowercase(); |
| 119 | DEEPSEEK_EFFORT_ALIASES |
| 120 | .iter() |
| 121 | .find(|(alias, _)| *alias == normalized) |
| 122 | .map(|(_, tier)| *tier) |
| 123 | } |
| 124 | |
| 125 | /// Look up a raw effort string, falling back to |
| 126 | /// [`DEEPSEEK_DEFAULT_EFFORT_TIER`] for unknown spellings. |
| 127 | pub(super) fn deepseek_effort_tier_or_default(raw: &str) -> DeepseekEffortTier { |
| 128 | deepseek_effort_tier(raw).unwrap_or(DEEPSEEK_DEFAULT_EFFORT_TIER) |
| 129 | } |
| 130 | |
| 131 | #[cfg(test)] |
| 132 | mod tests { |
| 133 | use super::*; |
| 134 | |
| 135 | /// DeepSeek's documented requested-to-actual mapping (Thinking Mode |
| 136 | /// guide, verified 2026-09-17). Pin every row so a future edit to the |
| 137 | /// table cannot silently disagree with the vendor. |
| 138 | #[test] |
| 139 | fn vendor_requested_to_actual_mapping_is_pinned() { |
| 140 | for (requested, tier) in [ |
| 141 | ("minimal", DeepseekEffortTier::Low), |
| 142 | ("low", DeepseekEffortTier::Low), |
| 143 | ("medium", DeepseekEffortTier::High), |
| 144 | ("high", DeepseekEffortTier::High), |
| 145 | ("xhigh", DeepseekEffortTier::High), |
| 146 | ("max", DeepseekEffortTier::Max), |
| 147 | ("ultra", DeepseekEffortTier::Max), |
| 148 | ] { |
| 149 | assert_eq!(deepseek_effort_tier(requested), Some(tier), "{requested}"); |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn aliases_are_unique_and_normalized() { |
| 155 | for (index, (alias, _)) in DEEPSEEK_EFFORT_ALIASES.iter().enumerate() { |
| 156 | assert_eq!( |
| 157 | *alias, |
| 158 | alias.trim().to_ascii_lowercase(), |
| 159 | "alias {alias:?} must be stored pre-normalized" |
| 160 | ); |
| 161 | assert!( |
| 162 | !DEEPSEEK_EFFORT_ALIASES[..index] |
| 163 | .iter() |
| 164 | .any(|(seen, _)| seen == alias), |
| 165 | "duplicate alias {alias:?}" |
| 166 | ); |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn lookup_is_case_and_whitespace_insensitive() { |
| 172 | assert_eq!( |
| 173 | deepseek_effort_tier(" MAX "), |
| 174 | Some(DeepseekEffortTier::Max) |
| 175 | ); |
| 176 | assert_eq!(deepseek_effort_tier("auto"), None); |
| 177 | assert_eq!( |
| 178 | deepseek_effort_tier_or_default("auto"), |
| 179 | DeepseekEffortTier::High |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | #[test] |
| 184 | fn ultra_and_legacy_ultracode_alias_resolve_to_max() { |
| 185 | assert_eq!(deepseek_effort_tier("ultra"), Some(DeepseekEffortTier::Max)); |
| 186 | assert_eq!( |
| 187 | deepseek_effort_tier("ultracode"), |
| 188 | Some(DeepseekEffortTier::Max) |
| 189 | ); |
| 190 | assert_eq!( |
| 191 | deepseek_effort_tier_or_default("ultra").responses_effort(), |
| 192 | "max" |
| 193 | ); |
| 194 | } |
| 195 | |
| 196 | #[test] |
| 197 | fn every_tier_has_both_wire_spellings() { |
| 198 | for tier in [ |
| 199 | DeepseekEffortTier::Off, |
| 200 | DeepseekEffortTier::Low, |
| 201 | DeepseekEffortTier::High, |
| 202 | DeepseekEffortTier::Max, |
| 203 | ] { |
| 204 | let responses = tier.responses_effort(); |
| 205 | assert!(!responses.is_empty(), "{tier:?} needs a Responses label"); |
| 206 | match tier { |
| 207 | DeepseekEffortTier::Off => { |
| 208 | assert_eq!(tier.chat_reasoning_effort(), None); |
| 209 | assert!(!tier.chat_thinking_enabled()); |
| 210 | } |
| 211 | _ => { |
| 212 | assert_eq!(tier.chat_reasoning_effort(), Some(responses)); |
| 213 | assert!(tier.chat_thinking_enabled()); |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 |