1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26pub enum NumCtxSource {
27 Override,
29 GlobalConfig,
31 Auto,
33 AutoFallback,
36 Cloud,
39}
40
41impl NumCtxSource {
42 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 pub fn is_auto(self) -> bool {
56 matches!(self, NumCtxSource::Auto | NumCtxSource::AutoFallback)
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct NumCtxResolution {
63 pub value: usize,
64 pub source: NumCtxSource,
65}
66
67#[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
78pub 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
104pub 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
120pub 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; }
144 let overflow = total_bytes - size_vram_bytes;
145 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 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#[derive(Debug, Clone, Copy, Default)]
161pub struct NumCtxInputs {
162 pub model_max: Option<usize>,
165 pub dims: Option<ModelDims>,
167 pub model_weight_bytes: Option<u64>,
169 pub per_model_override: Option<u32>,
171 pub global_num_ctx: Option<i32>,
173 pub allow_ram_offload: bool,
177 pub vram_bytes: Option<u64>,
179 pub system_ram_bytes: Option<u64>,
181 pub max_auto_cap: Option<usize>,
183 pub is_cloud: bool,
187}
188
189pub fn resolve_ollama_num_ctx(inputs: &NumCtxInputs) -> Option<NumCtxResolution> {
194 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 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 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 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 _ => (DEFAULT_OLLAMA_MAX_AUTO_NUM_CTX, NumCtxSource::AutoFallback),
240 };
241
242 let target = round_down_to(raw_target, OLLAMA_NUM_CTX_ROUNDING);
245
246 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 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
264pub 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 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 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, 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 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 assert!((28_000..=32_000).contains(&got), "expected ~30k, got {got}");
339 }
340
341 #[test]
342 fn converge_shrinks_to_clear_kv_overflow() {
343 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 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 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 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 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 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 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 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 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), global_num_ctx: Some(16_384), ..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 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 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 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 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 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 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 assert!(resolve_ollama_num_ctx(&NumCtxInputs::default()).is_none());
578 }
579
580 #[test]
581 fn num_predict_auto_uses_full_room() {
582 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 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 assert_eq!(
599 default_ollama_num_predict(4_096, Some(131_072), 1_000, None),
600 Some(4_096)
601 );
602 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 assert_eq!(
613 default_ollama_num_predict(4_096, Some(8_192), 8_100, None),
614 Some(512)
615 );
616 }
617}