Skip to main content

mermaid_model/models/adapters/
ollama_sizing.rs

1//! Pure, I/O-free sizing logic for Ollama's `num_ctx` and `num_predict`.
2//!
3//! Ollama defaults `num_ctx` to a tiny window (~4096) and never receives an
4//! output cap from Mermaid, so long prompts and reasoning models truncate. This
5//! module derives both numbers from the model's *real* capabilities (probed via
6//! `/api/show`) and the host's memory, so users never have to touch Ollama
7//! config.
8//!
9//! Everything here is pure: detection (memory, the `/api/show` probe) happens in
10//! the caller and is passed in via [`NumCtxInputs`]. The same inputs are built at
11//! both call sites — the request path (`providers/model/ollama.rs`) and the
12//! compaction/UI path (`effect/mod.rs`) — so the effective window can never
13//! disagree between what Ollama is told and what compaction/the status bar
14//! assume.
15
16use super::output_budget::{OutputBudgetInputs, OutputCapMode, resolve_output_budget};
17use crate::constants::{
18    DEFAULT_OLLAMA_MAX_AUTO_NUM_CTX, OLLAMA_KV_DTYPE_BYTES, OLLAMA_MEMORY_BUDGET_FRACTION,
19    OLLAMA_MIN_AUTO_NUM_CTX, OLLAMA_MIN_NUM_PREDICT, OLLAMA_NUM_CTX_ROUNDING,
20    OLLAMA_NUM_PREDICT_MARGIN,
21};
22
23/// How the effective `num_ctx` was chosen — surfaced in `/context` and the
24/// quick-fix hints.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26pub enum NumCtxSource {
27    /// Per-model override set via `/context <n>` / `/context max`.
28    Override,
29    /// Global `[ollama] num_ctx` from config.
30    GlobalConfig,
31    /// Auto-fit to detected memory, capped at the model's max window.
32    Auto,
33    /// Auto, but memory/dimensions couldn't be detected, so the conservative
34    /// fallback cap was used.
35    AutoFallback,
36    /// A cloud-served (`:cloud`) model: it runs on Ollama's servers, not the local
37    /// GPU, so it uses its full advertised window rather than a VRAM-based fit.
38    Cloud,
39}
40
41impl NumCtxSource {
42    /// Human label for `/context` and hints.
43    pub fn label(self) -> &'static str {
44        match self {
45            NumCtxSource::Override => "override",
46            NumCtxSource::GlobalConfig => "config",
47            NumCtxSource::Auto => "auto",
48            NumCtxSource::AutoFallback => "auto (fallback)",
49            NumCtxSource::Cloud => "cloud (full window)",
50        }
51    }
52
53    /// Whether the window was auto-fitted (vs an explicit user/config value) —
54    /// drives whether the quick-fix offers to raise it.
55    pub fn is_auto(self) -> bool {
56        matches!(self, NumCtxSource::Auto | NumCtxSource::AutoFallback)
57    }
58}
59
60/// Resolved effective `num_ctx` plus how it was chosen.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct NumCtxResolution {
63    pub value: usize,
64    pub source: NumCtxSource,
65}
66
67/// Architecture dimensions from `/api/show` `model_info`, used to estimate the
68/// KV-cache cost per token. All fields are required for the estimate; a missing
69/// one collapses to "unknown" and the resolver falls back to the conservative cap.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
71pub struct ModelDims {
72    pub block_count: usize,
73    pub head_count: usize,
74    pub head_count_kv: usize,
75    pub embedding_length: usize,
76}
77
78/// KV-cache bytes per token (both K and V tensors, fp16). `None` if any
79/// dimension is missing/zero.
80///
81/// `2 (K+V) * block_count * head_count_kv * head_dim * bytes`, where
82/// `head_dim = embedding_length / head_count`. Using `head_count_kv` (not
83/// `head_count`) accounts for grouped-query attention, which most modern models
84/// use to shrink the KV cache.
85pub fn kv_bytes_per_token(dims: &ModelDims) -> Option<usize> {
86    if dims.block_count == 0
87        || dims.head_count == 0
88        || dims.head_count_kv == 0
89        || dims.embedding_length == 0
90    {
91        return None;
92    }
93    let head_dim = dims.embedding_length / dims.head_count;
94    if head_dim == 0 {
95        return None;
96    }
97    2usize
98        .checked_mul(dims.block_count)?
99        .checked_mul(dims.head_count_kv)?
100        .checked_mul(head_dim)?
101        .checked_mul(OLLAMA_KV_DTYPE_BYTES)
102}
103
104/// Largest number of tokens whose KV cache fits `budget_bytes` after reserving a
105/// headroom fraction and subtracting the model weights. `None` if
106/// `kv_bytes_per_token` is zero.
107pub fn max_tokens_for_memory(
108    budget_bytes: u64,
109    model_weight_bytes: u64,
110    kv_bytes_per_token: usize,
111) -> Option<usize> {
112    if kv_bytes_per_token == 0 {
113        return None;
114    }
115    let usable = (budget_bytes as f64 * OLLAMA_MEMORY_BUDGET_FRACTION) as u64;
116    let for_kv = usable.saturating_sub(model_weight_bytes);
117    Some((for_kv / kv_bytes_per_token as u64) as usize)
118}
119
120/// Auto-converge step: when a turn spilled out of VRAM, the largest `num_ctx`
121/// that drops enough KV-cache tokens to clear the measured `total - size_vram`
122/// overflow (rounded down to a clean step, floored at the usable minimum).
123///
124/// Works from the *observed* footprint, so it implicitly accounts for compute
125/// buffers and Ollama's own headroom rather than re-estimating them.
126///
127/// Returns `None` when shrinking can't actually make the model fit — crucially,
128/// when even cutting all the way to the floor wouldn't clear the overflow. That
129/// means the spill is the *weights*, not the KV cache, so shrinking the window
130/// would cripple it without fixing anything; the caller must warn instead of
131/// flooring uselessly (a tiny window wedges the session — every turn truncates).
132/// When it does return a value it is strictly `< current` *and* genuinely clears
133/// the overflow, so feeding it back each turn converges to the largest
134/// fully-resident window.
135pub fn converge_num_ctx(
136    current: usize,
137    size_vram_bytes: u64,
138    total_bytes: u64,
139    kv_bytes_per_token: usize,
140) -> Option<usize> {
141    if kv_bytes_per_token == 0 || total_bytes <= size_vram_bytes {
142        return None; // KV cost unknown, or it actually fit — nothing to do.
143    }
144    let overflow = total_bytes - size_vram_bytes;
145    // Round the division up so we cut at least the overflow, never one short.
146    let tokens_to_cut = overflow.div_ceil(kv_bytes_per_token as u64) as usize;
147    let after_cut = current.saturating_sub(tokens_to_cut);
148    // If clearing the overflow would require dropping below the floor, the model
149    // is weights-bound: no window size makes it fit, so don't shrink at all.
150    // Flooring would neither stop the spill nor leave a usable window.
151    if after_cut < OLLAMA_MIN_AUTO_NUM_CTX {
152        return None;
153    }
154    let target = round_down_to(after_cut, OLLAMA_NUM_CTX_ROUNDING).max(OLLAMA_MIN_AUTO_NUM_CTX);
155    (target < current).then_some(target)
156}
157
158/// Everything [`resolve_ollama_num_ctx`] needs. Built identically at both call
159/// sites so the effective window stays consistent.
160#[derive(Debug, Clone, Copy, Default)]
161pub struct NumCtxInputs {
162    /// The model's architectural max window (from `/api/show`). Without it, auto
163    /// can't run and the resolver returns `None` (caller omits `num_ctx`).
164    pub model_max: Option<usize>,
165    /// Architecture dimensions for the KV-cache estimate.
166    pub dims: Option<ModelDims>,
167    /// Model weight bytes (from `/api/show` `size`), subtracted from the budget.
168    pub model_weight_bytes: Option<u64>,
169    /// Per-model override (`/context <n>`); highest precedence.
170    pub per_model_override: Option<u32>,
171    /// Global `[ollama] num_ctx`; beats auto, loses to the override.
172    pub global_num_ctx: Option<i32>,
173    /// When true, auto-fit budgets against system RAM (allows the slow GPU/CPU
174    /// split); when false (default), budgets against VRAM so the model stays on
175    /// the GPU.
176    pub allow_ram_offload: bool,
177    /// Total VRAM in bytes (best-effort), used as the budget when offload is off.
178    pub vram_bytes: Option<u64>,
179    /// Total system RAM in bytes, used as the budget when offload is on.
180    pub system_ram_bytes: Option<u64>,
181    /// Optional hard cap on the auto value (`[ollama] max_auto_num_ctx`).
182    pub max_auto_cap: Option<usize>,
183    /// True for a cloud-served (`:cloud`) model. These run on Ollama's servers,
184    /// not the local GPU, so they bypass the VRAM auto-fit and use the model's
185    /// full advertised window.
186    pub is_cloud: bool,
187}
188
189/// Resolve the effective `num_ctx`. Precedence: **per-model override > global
190/// config > auto-fit**. Returns `None` only when nothing is known (no override,
191/// no global, no probed `model_max`) — the caller then omits `num_ctx` entirely
192/// and Ollama uses its own default.
193pub fn resolve_ollama_num_ctx(inputs: &NumCtxInputs) -> Option<NumCtxResolution> {
194    // 1. Explicit per-model override wins outright.
195    if let Some(n) = inputs.per_model_override.filter(|n| *n > 0) {
196        return Some(NumCtxResolution {
197            value: n as usize,
198            source: NumCtxSource::Override,
199        });
200    }
201    // 1b. Cloud (`:cloud`) models run on Ollama's servers, not the local GPU, so a
202    // VRAM-based fit is meaningless — use the model's full advertised window. (An
203    // explicit per-model override above still wins; the global config below is a
204    // local-memory knob that shouldn't shrink a remote model.) Window unknown →
205    // None, so the caller omits num_ctx and the cloud service uses its default.
206    if inputs.is_cloud {
207        return inputs
208            .model_max
209            .filter(|m| *m > 0)
210            .map(|model_max| NumCtxResolution {
211                value: model_max,
212                source: NumCtxSource::Cloud,
213            });
214    }
215    // 2. Global config num_ctx.
216    if let Some(n) = inputs.global_num_ctx.filter(|n| *n > 0) {
217        return Some(NumCtxResolution {
218            value: n as usize,
219            source: NumCtxSource::GlobalConfig,
220        });
221    }
222    // 3. Auto-fit. Needs the model's max window to bound everything.
223    let model_max = inputs.model_max.filter(|m| *m > 0)?;
224
225    let budget_bytes = if inputs.allow_ram_offload {
226        inputs.system_ram_bytes
227    } else {
228        inputs.vram_bytes
229    };
230    let kv = inputs.dims.as_ref().and_then(kv_bytes_per_token);
231
232    let (raw_target, source) = match (budget_bytes, kv) {
233        (Some(b), Some(kv)) => {
234            let fit = max_tokens_for_memory(b, inputs.model_weight_bytes.unwrap_or(0), kv)
235                .unwrap_or(DEFAULT_OLLAMA_MAX_AUTO_NUM_CTX);
236            (fit, NumCtxSource::Auto)
237        },
238        // Memory or dimensions unknown → conservative fixed fallback.
239        _ => (DEFAULT_OLLAMA_MAX_AUTO_NUM_CTX, NumCtxSource::AutoFallback),
240    };
241
242    // Round the memory-derived target to a clean step (no-op for the fallback,
243    // which is already a clean multiple).
244    let target = round_down_to(raw_target, OLLAMA_NUM_CTX_ROUNDING);
245
246    // The model's own max binds exactly (don't round it down and waste tokens).
247    let mut value = target.min(model_max);
248    if let Some(cap) = inputs.max_auto_cap.filter(|c| *c > 0) {
249        value = value.min(cap);
250    }
251    // Floor at a usable minimum, but never above the model's own max.
252    value = value.max(OLLAMA_MIN_AUTO_NUM_CTX.min(model_max));
253
254    Some(NumCtxResolution { value, source })
255}
256
257fn round_down_to(v: usize, step: usize) -> usize {
258    if step == 0 {
259        return v;
260    }
261    (v / step) * step
262}
263
264/// Resolve Ollama's `num_predict` (its output cap) from the request's
265/// `max_tokens` (`0` = AUTO) and the room `num_ctx` leaves after the prompt.
266/// `Some(n)` = send `n`; `None` = omit `num_predict` so Ollama applies its own
267/// default. A thin wrapper over the shared [`resolve_output_budget`] with
268/// Ollama's floor/margin — AUTO hands over the full remaining window room, a
269/// positive `max_tokens` is an exact cap bounded by that room.
270///
271/// `provider_max_output` is the model's real per-response ceiling when one is
272/// known — local Ollama imposes none, but Ollama Cloud maps `num_predict` →
273/// `max_tokens` and 400s above the model's cap (learned from such a 400 and
274/// cached). It bounds both AUTO and explicit asks.
275pub fn default_ollama_num_predict(
276    max_tokens: usize,
277    num_ctx: Option<usize>,
278    prompt_estimate: usize,
279    provider_max_output: Option<usize>,
280) -> Option<i32> {
281    resolve_output_budget(
282        &OutputBudgetInputs {
283            requested_cap: max_tokens,
284            window: num_ctx,
285            prompt_estimate,
286            provider_max_output,
287            margin: OLLAMA_NUM_PREDICT_MARGIN,
288            floor: OLLAMA_MIN_NUM_PREDICT,
289        },
290        OutputCapMode::NumPredict,
291    )
292    .map(|v| v.min(i32::MAX as usize) as i32)
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    // A realistic GQA model (~7-9B, like the user's ornith): 28 layers, 28 heads,
300    // 4 KV heads, 3584 embedding → head_dim 128 → 56 KB/token.
301    fn gqa_dims() -> ModelDims {
302        ModelDims {
303            block_count: 28,
304            head_count: 28,
305            head_count_kv: 4,
306            embedding_length: 3584,
307        }
308    }
309
310    #[test]
311    fn kv_bytes_per_token_gqa() {
312        // 2 * 28 * 4 * (3584/28=128) * 2 = 57_344 bytes.
313        assert_eq!(kv_bytes_per_token(&gqa_dims()), Some(57_344));
314    }
315
316    #[test]
317    fn kv_bytes_per_token_missing_dims_is_none() {
318        assert_eq!(kv_bytes_per_token(&ModelDims::default()), None);
319        assert_eq!(
320            kv_bytes_per_token(&ModelDims {
321                block_count: 28,
322                head_count: 0, // would divide by zero
323                head_count_kv: 4,
324                embedding_length: 3584,
325            }),
326            None
327        );
328    }
329
330    #[test]
331    fn max_tokens_for_memory_subtracts_weights_and_headroom() {
332        // 8 GiB budget, ~5.6 GB model, 56 KB/token.
333        let budget = 8 * 1024 * 1024 * 1024u64;
334        let weight = 5_600_000_000u64;
335        let kv = 57_344usize;
336        let got = max_tokens_for_memory(budget, weight, kv).unwrap();
337        // usable = 0.85 * 8GiB ≈ 7.3 GB; for_kv ≈ 1.7 GB; /56KB ≈ 30k tokens.
338        assert!((28_000..=32_000).contains(&got), "expected ~30k, got {got}");
339    }
340
341    #[test]
342    fn converge_shrinks_to_clear_kv_overflow() {
343        // 10k-token window spilled by ~2 GB of KV (2000 tokens @ 1 MB/token).
344        let new = converge_num_ctx(10_000, 10_000_000_000, 12_000_000_000, 1_000_000)
345            .expect("should shrink");
346        assert!(new < 10_000, "must make progress, got {new}");
347        // ~2000 tokens cut from 10000 → ~8000, rounded down to the 1024 step.
348        assert!((6_000..=8_000).contains(&new), "expected ~7-8k, got {new}");
349    }
350
351    #[test]
352    fn converge_none_when_overflow_is_weights_bound() {
353        // Overflow worth more tokens than the window has above the floor → even
354        // flooring wouldn't clear it (the spill is weights, not KV). Must NOT
355        // shrink (that would wedge the session); the caller warns instead.
356        let new = converge_num_ctx(8_000, 1_000_000_000, 9_000_000_000, 1_000_000);
357        assert_eq!(new, None);
358    }
359
360    #[test]
361    fn converge_some_only_when_shrink_actually_clears_overflow() {
362        // A 16k window spilling by ~6k tokens of KV: cutting to ~10k clears it and
363        // stays well above the floor → a real, useful shrink.
364        let new = converge_num_ctx(16_000, 10_000_000_000, 16_000_000_000, 1_000_000)
365            .expect("clearable overflow should shrink");
366        assert!(
367            (OLLAMA_MIN_AUTO_NUM_CTX..16_000).contains(&new),
368            "got {new}"
369        );
370        // Same overflow on a window only just above the floor can't be cleared
371        // without dropping below it → bail.
372        assert_eq!(
373            converge_num_ctx(5_000, 10_000_000_000, 16_000_000_000, 1_000_000),
374            None
375        );
376    }
377
378    #[test]
379    fn converge_none_when_already_at_floor() {
380        // Already at the floor and still spilling → caller must warn, not loop.
381        let new = converge_num_ctx(
382            OLLAMA_MIN_AUTO_NUM_CTX,
383            1_000_000_000,
384            9_000_000_000,
385            1_000_000,
386        );
387        assert_eq!(new, None);
388    }
389
390    #[test]
391    fn converge_none_when_it_fits_or_kv_unknown() {
392        // Fully resident (total == vram) or under it → nothing to do.
393        assert_eq!(
394            converge_num_ctx(8_000, 6_000_000_000, 6_000_000_000, 40_000),
395            None
396        );
397        assert_eq!(
398            converge_num_ctx(8_000, 6_000_000_000, 5_000_000_000, 40_000),
399            None
400        );
401        // KV cost unknown → can't compute → leave it alone.
402        assert_eq!(
403            converge_num_ctx(8_000, 1_000_000_000, 9_000_000_000, 0),
404            None
405        );
406    }
407
408    #[test]
409    fn override_beats_everything() {
410        let res = resolve_ollama_num_ctx(&NumCtxInputs {
411            model_max: Some(262_144),
412            per_model_override: Some(131_072),
413            global_num_ctx: Some(8_192),
414            vram_bytes: Some(8 * 1024 * 1024 * 1024),
415            dims: Some(gqa_dims()),
416            ..Default::default()
417        })
418        .unwrap();
419        assert_eq!(res.value, 131_072);
420        assert_eq!(res.source, NumCtxSource::Override);
421    }
422
423    #[test]
424    fn global_config_beats_auto() {
425        let res = resolve_ollama_num_ctx(&NumCtxInputs {
426            model_max: Some(262_144),
427            global_num_ctx: Some(16_384),
428            vram_bytes: Some(8 * 1024 * 1024 * 1024),
429            dims: Some(gqa_dims()),
430            ..Default::default()
431        })
432        .unwrap();
433        assert_eq!(res.value, 16_384);
434        assert_eq!(res.source, NumCtxSource::GlobalConfig);
435    }
436
437    #[test]
438    fn cloud_model_uses_full_window_ignoring_vram_and_global() {
439        // A `:cloud` model runs remotely, so it ignores the local GPU's VRAM (and a
440        // global num_ctx, which is a local-memory knob) and uses its full window.
441        let res = resolve_ollama_num_ctx(&NumCtxInputs {
442            model_max: Some(524_288),
443            is_cloud: true,
444            dims: Some(gqa_dims()),
445            vram_bytes: Some(8 * 1024 * 1024 * 1024), // small GPU — must be ignored
446            global_num_ctx: Some(16_384),             // local knob — ignored for cloud
447            ..Default::default()
448        })
449        .unwrap();
450        assert_eq!(res.value, 524_288, "cloud uses the full advertised window");
451        assert_eq!(res.source, NumCtxSource::Cloud);
452        assert!(!res.source.is_auto(), "cloud is not a VRAM auto-fit");
453    }
454
455    #[test]
456    fn cloud_model_still_honors_explicit_override() {
457        // An explicit `/context <n>` caps even a cloud model.
458        let res = resolve_ollama_num_ctx(&NumCtxInputs {
459            model_max: Some(524_288),
460            is_cloud: true,
461            per_model_override: Some(65_536),
462            ..Default::default()
463        })
464        .unwrap();
465        assert_eq!(res.value, 65_536);
466        assert_eq!(res.source, NumCtxSource::Override);
467    }
468
469    #[test]
470    fn cloud_model_without_known_window_omits_num_ctx() {
471        // Window unknown → None, so the caller omits num_ctx and the cloud default
472        // applies (never a VRAM-derived fallback).
473        let res = resolve_ollama_num_ctx(&NumCtxInputs {
474            model_max: None,
475            is_cloud: true,
476            ..Default::default()
477        });
478        assert!(res.is_none());
479    }
480
481    #[test]
482    fn auto_fits_vram_and_caps_at_model_max() {
483        // Huge VRAM but small model_max → model_max binds exactly.
484        let res = resolve_ollama_num_ctx(&NumCtxInputs {
485            model_max: Some(8_192),
486            dims: Some(gqa_dims()),
487            model_weight_bytes: Some(5_600_000_000),
488            vram_bytes: Some(80 * 1024 * 1024 * 1024),
489            ..Default::default()
490        })
491        .unwrap();
492        assert_eq!(res.value, 8_192);
493        assert_eq!(res.source, NumCtxSource::Auto);
494    }
495
496    #[test]
497    fn auto_fits_vram_below_model_max() {
498        // 8 GB VRAM, 256K model → memory binds well under model_max, rounded.
499        let res = resolve_ollama_num_ctx(&NumCtxInputs {
500            model_max: Some(262_144),
501            dims: Some(gqa_dims()),
502            model_weight_bytes: Some(5_600_000_000),
503            vram_bytes: Some(8 * 1024 * 1024 * 1024),
504            ..Default::default()
505        })
506        .unwrap();
507        assert_eq!(res.source, NumCtxSource::Auto);
508        assert!(res.value < 262_144 && res.value >= OLLAMA_MIN_AUTO_NUM_CTX);
509        assert_eq!(res.value % OLLAMA_NUM_CTX_ROUNDING, 0);
510    }
511
512    #[test]
513    fn offload_on_uses_system_ram() {
514        // No VRAM, but offload allowed and lots of RAM → much bigger window than
515        // the VRAM-off path (which would fall back to the cap).
516        let res = resolve_ollama_num_ctx(&NumCtxInputs {
517            model_max: Some(262_144),
518            dims: Some(gqa_dims()),
519            model_weight_bytes: Some(5_600_000_000),
520            allow_ram_offload: true,
521            system_ram_bytes: Some(64 * 1024 * 1024 * 1024),
522            vram_bytes: None,
523            ..Default::default()
524        })
525        .unwrap();
526        assert_eq!(res.source, NumCtxSource::Auto);
527        assert!(
528            res.value > DEFAULT_OLLAMA_MAX_AUTO_NUM_CTX,
529            "64GB RAM should fit > 32k, got {}",
530            res.value
531        );
532    }
533
534    #[test]
535    fn no_memory_detected_uses_fallback_cap() {
536        let res = resolve_ollama_num_ctx(&NumCtxInputs {
537            model_max: Some(262_144),
538            dims: Some(gqa_dims()),
539            vram_bytes: None,
540            system_ram_bytes: None,
541            ..Default::default()
542        })
543        .unwrap();
544        assert_eq!(res.value, DEFAULT_OLLAMA_MAX_AUTO_NUM_CTX);
545        assert_eq!(res.source, NumCtxSource::AutoFallback);
546    }
547
548    #[test]
549    fn max_auto_cap_clamps_auto() {
550        let res = resolve_ollama_num_ctx(&NumCtxInputs {
551            model_max: Some(262_144),
552            dims: Some(gqa_dims()),
553            model_weight_bytes: Some(5_600_000_000),
554            vram_bytes: Some(80 * 1024 * 1024 * 1024),
555            max_auto_cap: Some(16_384),
556            ..Default::default()
557        })
558        .unwrap();
559        assert_eq!(res.value, 16_384);
560    }
561
562    #[test]
563    fn floor_never_exceeds_model_max() {
564        // Tiny model_max below the floor → use model_max, not the floor.
565        let res = resolve_ollama_num_ctx(&NumCtxInputs {
566            model_max: Some(2_048),
567            vram_bytes: None,
568            ..Default::default()
569        })
570        .unwrap();
571        assert_eq!(res.value, 2_048);
572    }
573
574    #[test]
575    fn no_model_max_and_no_explicit_is_none() {
576        // Probe failed and nothing configured → omit num_ctx (Ollama default).
577        assert!(resolve_ollama_num_ctx(&NumCtxInputs::default()).is_none());
578    }
579
580    #[test]
581    fn num_predict_auto_uses_full_room() {
582        // AUTO (max_tokens 0) → all the room the window leaves.
583        assert_eq!(
584            default_ollama_num_predict(0, Some(131_072), 1_000, None),
585            Some(131_072 - 1_000 - 256)
586        );
587    }
588
589    #[test]
590    fn num_predict_auto_omits_without_ctx() {
591        // Unknown window → omit num_predict so Ollama applies its own default.
592        assert_eq!(default_ollama_num_predict(0, None, 1_000, None), None);
593    }
594
595    #[test]
596    fn num_predict_hard_cap_is_exact_and_room_bounded() {
597        // Explicit cap with plenty of room → exactly the cap (no reserve added).
598        assert_eq!(
599            default_ollama_num_predict(4_096, Some(131_072), 1_000, None),
600            Some(4_096)
601        );
602        // num_ctx 8k, prompt 7k, margin 256 → bounded by the remaining room.
603        assert_eq!(
604            default_ollama_num_predict(4_096, Some(8_192), 7_000, None),
605            Some(8_192 - 7_000 - 256)
606        );
607    }
608
609    #[test]
610    fn num_predict_floored_when_room_tiny() {
611        // Prompt nearly fills the window → the floor, not zero/negative.
612        assert_eq!(
613            default_ollama_num_predict(4_096, Some(8_192), 8_100, None),
614            Some(512)
615        );
616    }
617}