Skip to main content

mermaid_model/models/adapters/
output_budget.rs

1//! Model-scaled output-token budgeting, shared across adapters.
2//!
3//! `max_tokens == 0` means **AUTO**: give the model the room the context window
4//! leaves, or omit the cap entirely so the provider applies its own per-response
5//! maximum — never a guessed constant. A positive value is the user's explicit
6//! **hard cap**. This generalizes the sizing Ollama has always done (see
7//! [`super::ollama_sizing`]) to every provider.
8
9use crate::models::reasoning::ReasoningLevel;
10
11/// Output tokens reserved for reasoning/thinking so a thinking model doesn't
12/// burn its whole answer budget before responding. Scales with the effort
13/// level. Used by compaction to reserve response room in the window; the AUTO
14/// budget itself needs no reserve (it already hands over the full window room).
15pub fn reasoning_output_reserve(level: ReasoningLevel) -> usize {
16    match level {
17        ReasoningLevel::None | ReasoningLevel::Minimal => 0,
18        ReasoningLevel::Low => 1_024,
19        ReasoningLevel::Medium => 4_096,
20        ReasoningLevel::High | ReasoningLevel::XHigh | ReasoningLevel::Max => 8_192,
21    }
22}
23
24/// How a provider accepts its per-request output cap. Providers whose cap
25/// field is *omittable* (OpenAI-compatible, Gemini) don't come through here:
26/// their adapters implement AUTO by omitting the field when `max_tokens == 0`
27/// (`openai_compat.rs`/`gemini.rs` request builders), which yields the
28/// provider's own per-response maximum with no computed number at all.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum OutputCapMode {
31    /// The field is mandatory (Anthropic). AUTO → a computed positive value.
32    Required,
33    /// Ollama's `num_predict`, sent when the window is known so generation stops
34    /// before the window fills. AUTO → the full remaining window room.
35    NumPredict,
36}
37
38/// Inputs for [`resolve_output_budget`].
39#[derive(Debug, Clone, Copy)]
40pub struct OutputBudgetInputs {
41    /// The requested cap — `ChatRequest.max_tokens`. `0` = AUTO.
42    pub requested_cap: usize,
43    /// Effective context window in tokens, if known.
44    pub window: Option<usize>,
45    /// Estimated prompt tokens already occupying the window.
46    pub prompt_estimate: usize,
47    /// The model's real per-response output ceiling, if known (live-discovered
48    /// or learned from a 400). Bounds every resolved value — sending above it
49    /// is a guaranteed rejection on providers that enforce one.
50    pub provider_max_output: Option<usize>,
51    /// Tokens held back from the window for prompt-estimate slop / safety.
52    pub margin: usize,
53    /// Minimum output when a positive value must be sent.
54    pub floor: usize,
55}
56
57/// Resolve the per-request output cap. `Some(n)` = send `n`; `None` = omit the
58/// field entirely (let the provider apply its own per-response maximum).
59pub fn resolve_output_budget(inputs: &OutputBudgetInputs, mode: OutputCapMode) -> Option<usize> {
60    // Room the window leaves after the prompt. `Some(0)` when the window is
61    // already full; `None` when the window is unknown — kept distinct so a full
62    // window clamps output while an unknown window does not.
63    let room = inputs.window.map(|w| {
64        w.saturating_sub(inputs.prompt_estimate)
65            .saturating_sub(inputs.margin)
66    });
67
68    // Explicit hard cap: honor it, bounded by the room that actually fits and
69    // by the model's known output ceiling (above it is a guaranteed 400).
70    if inputs.requested_cap > 0 {
71        let mut capped = room.map_or(inputs.requested_cap, |r| inputs.requested_cap.min(r));
72        if let Some(ceiling) = inputs.provider_max_output {
73            capped = capped.min(ceiling);
74        }
75        return Some(match mode {
76            OutputCapMode::NumPredict => capped.max(inputs.floor),
77            OutputCapMode::Required => capped.max(1),
78        });
79    }
80
81    // AUTO.
82    match mode {
83        // Ollama must be told a number when the window is known (else it runs
84        // to the window edge and truncates), bounded by any known ceiling
85        // (Ollama Cloud enforces one — the minimax incident). A known ceiling
86        // with an unknown window is still sent: it's the one number that
87        // prevents the 400. Both unknown → omit (Ollama's own default).
88        OutputCapMode::NumPredict => match (room, inputs.provider_max_output) {
89            (Some(r), Some(ceiling)) => Some(r.min(ceiling).max(inputs.floor)),
90            (Some(r), None) => Some(r.max(inputs.floor)),
91            (None, Some(ceiling)) => Some(ceiling.max(inputs.floor)),
92            (None, None) => None,
93        },
94        // A required field: the model's real ceiling, clamped to what fits.
95        // Downstream (`anthropic::legacy_budget_for`) handles the
96        // thinking-budget fit against this value.
97        OutputCapMode::Required => {
98            let ceiling = inputs
99                .provider_max_output
100                .unwrap_or(inputs.floor.max(8_192));
101            Some(room.map_or(ceiling, |r| ceiling.min(r)).max(1))
102        },
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn inputs(requested_cap: usize, window: Option<usize>, prompt: usize) -> OutputBudgetInputs {
111        OutputBudgetInputs {
112            requested_cap,
113            window,
114            prompt_estimate: prompt,
115            provider_max_output: None,
116            margin: 256,
117            floor: 512,
118        }
119    }
120
121    #[test]
122    fn auto_num_predict_uses_full_room_or_omits() {
123        // Known window → all the room the window leaves (this is the GLM/Ollama fix).
124        assert_eq!(
125            resolve_output_budget(&inputs(0, Some(131_072), 1_000), OutputCapMode::NumPredict),
126            Some(131_072 - 1_000 - 256)
127        );
128        // Unknown window → omit (Ollama applies its own default).
129        assert_eq!(
130            resolve_output_budget(&inputs(0, None, 1_000), OutputCapMode::NumPredict),
131            None
132        );
133        // Full window → the floor, never zero.
134        assert_eq!(
135            resolve_output_budget(&inputs(0, Some(1_100), 1_000), OutputCapMode::NumPredict),
136            Some(512)
137        );
138    }
139
140    #[test]
141    fn auto_required_uses_documented_ceiling_clamped_to_room() {
142        let mut i = inputs(0, Some(200_000), 1_000);
143        i.provider_max_output = Some(64_000);
144        // Ceiling fits well within the window → send the ceiling.
145        assert_eq!(
146            resolve_output_budget(&i, OutputCapMode::Required),
147            Some(64_000)
148        );
149        // Tight window clamps below the ceiling.
150        i.prompt_estimate = 190_000;
151        assert_eq!(
152            resolve_output_budget(&i, OutputCapMode::Required),
153            Some(200_000 - 190_000 - 256)
154        );
155    }
156
157    #[test]
158    fn hard_cap_is_honored_and_room_bounded_without_reserve() {
159        // A user-set positive value is exact — no reasoning reserve added.
160        assert_eq!(
161            resolve_output_budget(
162                &inputs(4_096, Some(131_072), 1_000),
163                OutputCapMode::Required
164            ),
165            Some(4_096)
166        );
167        // Bounded by the room that fits.
168        assert_eq!(
169            resolve_output_budget(
170                &inputs(4_096, Some(8_192), 7_000),
171                OutputCapMode::NumPredict
172            ),
173            Some(8_192 - 7_000 - 256)
174        );
175        // Full window still floors NumPredict so something generates.
176        assert_eq!(
177            resolve_output_budget(
178                &inputs(4_096, Some(8_192), 8_100),
179                OutputCapMode::NumPredict
180            ),
181            Some(512)
182        );
183    }
184}