Skip to main content

gglib_core/domain/
reasoning_effort.rs

1//! The `reasoning_effort` level — how hard the model is *asked* to think.
2//!
3//! Kept out of [`inference`](super::inference) deliberately. This is not a
4//! sampler and it does not behave like one: it is a string that a chat
5//! template may read at render time, its level vocabulary is per-template
6//! folklore ([ADR 0007] finding 3), and the whole reason it exists as a type
7//! rather than a `String` is a governance argument that needs room to be
8//! written down. `inference.rs` is already the largest file in this module and
9//! the ladder is the thing it should be about; [`template_caps`] (the other
10//! half of ADR 0007) set the same precedent one PR ago.
11//!
12//! [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
13//! [`template_caps`]: super::template_caps
14
15use std::fmt;
16
17use serde::{Deserialize, Serialize};
18
19/// A reasoning-effort level gglib is willing to put on the wire.
20///
21/// # This enum is where gglib is deliberately stricter than upstream
22///
23/// [`InferenceConfig::extract_client_sampling`]'s coercion doctrine is "accept
24/// what upstream accepts and reject what upstream rejects, so gglib never
25/// becomes the stricter of the two". A closed enum breaks that rule for this
26/// one field, on purpose, and the exception is argued rather than assumed —
27/// see [`InferenceConfig::reasoning_effort`], which carries the argument.
28///
29/// # The levels
30///
31/// The six are llama-server's own documented set, taken from its
32/// `--reasoning-effort` help text on the pinned build. They are *offered*
33/// levels, not honoured ones: a template that never branches on the variable
34/// ignores every one of them, and even a template that reads it may only act
35/// on some (upstream's own tests show DeepSeek-V4 rendering something special
36/// for `"max"` and nothing at all for `"high"` or `"low"` — ADR 0007
37/// finding 3).
38///
39/// # `"none"` is not here, and its absence is the decision
40///
41/// llama-server accepts `reasoning_effort: "none"` and treats it specially: it
42/// **erases** the kwarg rather than passing it through. On `gpt-oss` the
43/// template's own `{%- set reasoning_effort = "medium" %}` fallback then
44/// fills the hole, so `"none"` yields **medium** thinking — confirmed live
45/// against the pinned binary, not inferred. Offering it as a level would ship
46/// a control whose most obvious value does the opposite of what it reads as.
47/// "Stop thinking" is
48/// [`reasoning_budget_tokens: 0`](super::InferenceConfig#structfield.reasoning_budget_tokens),
49/// which is sampler-enforced and range-validated upstream. See ADR 0007
50/// finding 4 and decision 4.
51///
52/// [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
53/// [`InferenceConfig::extract_client_sampling`]: super::InferenceConfig::extract_client_sampling
54/// [`InferenceConfig::reasoning_effort`]: super::InferenceConfig#structfield.reasoning_effort
55/// [`InferenceConfig::reasoning_budget_tokens`]: super::InferenceConfig#structfield.reasoning_budget_tokens
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
58#[serde(rename_all = "lowercase")]
59pub enum ReasoningEffort {
60    /// The least thinking the template offers.
61    Minimal,
62    /// Below the template's own middle setting.
63    Low,
64    /// The middle setting, and the value `gpt-oss`'s template falls back to on
65    /// its own when no kwarg arrives.
66    Medium,
67    /// Above the middle setting.
68    High,
69    /// Serialises as `xhigh`, one word — *not* `x_high`. The wire spelling is
70    /// llama-server's, and `serde(rename_all = "lowercase")` lower-cases the
71    /// variant name whole rather than splitting on the case boundary the way
72    /// `snake_case` would. `the_wire_spelling_of_every_level_is_pinned` fails
73    /// if that ever changes.
74    XHigh,
75    /// The most thinking the template offers.
76    Max,
77}
78
79impl ReasoningEffort {
80    /// Every level, weakest first.
81    ///
82    /// The order is the `--reasoning-effort` help text's, which is also the
83    /// only ordering the levels have — nothing in llama.cpp compares them, and
84    /// a template is free to treat `low` and `high` identically.
85    pub const ALL: [Self; 6] = [
86        Self::Minimal,
87        Self::Low,
88        Self::Medium,
89        Self::High,
90        Self::XHigh,
91        Self::Max,
92    ];
93
94    /// The wire spelling, identical to what `serde` emits.
95    ///
96    /// Both spellings exist because both are needed — `serde` writes the
97    /// request body, and this reads a client's string without a round trip
98    /// through `serde_json` — and they are pinned equal by test rather than by
99    /// hope.
100    #[must_use]
101    pub const fn as_str(self) -> &'static str {
102        match self {
103            Self::Minimal => "minimal",
104            Self::Low => "low",
105            Self::Medium => "medium",
106            Self::High => "high",
107            Self::XHigh => "xhigh",
108            Self::Max => "max",
109        }
110    }
111
112    /// Read a level from a client's string, or `None` if it is not one.
113    ///
114    /// Case-insensitive, because the levels are a closed vocabulary and
115    /// `"HIGH"` can only have meant one thing. Note what is *not* here:
116    /// `"none"` is not a level (see the type docs), so it lands in the `None`
117    /// arm along with `"banana"` and is reported as a rejection by the caller.
118    #[must_use]
119    pub fn from_wire(s: &str) -> Option<Self> {
120        Self::ALL
121            .into_iter()
122            .find(|level| level.as_str().eq_ignore_ascii_case(s))
123    }
124
125    /// The accepted levels, rendered for an error message.
126    #[must_use]
127    pub fn wire_vocabulary() -> String {
128        Self::ALL
129            .iter()
130            .map(|level| level.as_str())
131            .collect::<Vec<_>>()
132            .join(", ")
133    }
134}
135
136impl fmt::Display for ReasoningEffort {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(self.as_str())
139    }
140}
141
142#[cfg(test)]
143#[path = "reasoning_effort_tests.rs"]
144mod reasoning_effort_tests;