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}