1use std::time::Duration;
5
6use serde::Serialize;
7use serde_json::Value;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct OllamaWarmupResult {
11 pub valid: bool,
12 pub status: String,
13 pub message: String,
14 pub url: String,
15 pub model: String,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub http_status: Option<u16>,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct OllamaReadinessResult {
22 pub valid: bool,
23 pub status: String,
24 pub message: String,
25 pub base_url: String,
26 pub tags_url: String,
27 pub model: String,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub matched_model: Option<String>,
30 pub available_models: Vec<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub http_status: Option<u16>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub keep_alive: Option<serde_json::Value>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub warmup: Option<OllamaWarmupResult>,
37 #[serde(skip_serializing_if = "Option::is_none")]
40 pub expected: Option<OllamaExpectedRequest>,
41 #[serde(skip_serializing_if = "Option::is_none")]
45 pub loaded_runner: Option<OllamaLoadedRunner>,
46 #[serde(skip_serializing_if = "Option::is_none")]
49 pub context_drift: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize)]
56pub struct OllamaExpectedRequest {
57 pub num_ctx: u64,
58 pub keep_alive: Value,
59}
60
61#[derive(Debug, Clone, Serialize)]
66pub struct OllamaLoadedRunner {
67 pub name: String,
68 pub model: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub context_length: Option<u64>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub size_vram: Option<u64>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub size: Option<u64>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub expires_at: Option<String>,
77}
78
79#[derive(Debug, Clone)]
80pub struct OllamaReadinessOptions {
81 pub model: String,
82 pub base_url: Option<String>,
83 pub warm: bool,
84 pub keep_alive: Option<serde_json::Value>,
85 pub tags_timeout: Duration,
86 pub warmup_timeout: Duration,
87 pub observe_loaded: bool,
90}
91
92impl OllamaReadinessOptions {
93 pub fn new(model: impl Into<String>) -> Self {
94 Self {
95 model: model.into(),
96 base_url: None,
97 warm: false,
98 keep_alive: None,
99 tags_timeout: Duration::from_secs(15),
100 warmup_timeout: Duration::from_secs(135),
101 observe_loaded: false,
102 }
103 }
104}
105
106pub fn normalize_ollama_keep_alive(raw: &str) -> Option<serde_json::Value> {
110 parse_keep_alive_str(raw)
111}
112
113pub const OLLAMA_DEFAULT_NUM_CTX: u64 = 32_768;
114pub const OLLAMA_DEFAULT_KEEP_ALIVE: &str = "30m";
115pub const OLLAMA_DEFAULT_UNLOAD_GRACE_MS: u64 = 10_000;
116pub const HARN_OLLAMA_NUM_CTX_ENV: &str = "HARN_OLLAMA_NUM_CTX";
117pub const HARN_OLLAMA_KEEP_ALIVE_ENV: &str = "HARN_OLLAMA_KEEP_ALIVE";
118pub const HARN_OLLAMA_UNLOAD_GRACE_MS_ENV: &str = "HARN_OLLAMA_UNLOAD_GRACE_MS";
119pub const OLLAMA_UNLOAD_GRACE_MS_ENV: &str = "OLLAMA_UNLOAD_GRACE_MS";
120pub const OLLAMA_HOST_ENV: &str = "OLLAMA_HOST";
121
122const OLLAMA_NUM_CTX_ENV_KEYS: [&str; 3] = [
123 HARN_OLLAMA_NUM_CTX_ENV,
124 "OLLAMA_CONTEXT_LENGTH",
125 "OLLAMA_NUM_CTX",
126];
127const OLLAMA_KEEP_ALIVE_ENV_KEYS: [&str; 2] = [HARN_OLLAMA_KEEP_ALIVE_ENV, "OLLAMA_KEEP_ALIVE"];
128const OLLAMA_UNLOAD_GRACE_MS_ENV_KEYS: [&str; 2] =
129 [HARN_OLLAMA_UNLOAD_GRACE_MS_ENV, OLLAMA_UNLOAD_GRACE_MS_ENV];
130const OLLAMA_DEFAULT_BASE_URL: &str = "http://localhost:11434";
131
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct OllamaRuntimeSettings {
134 pub num_ctx: u64,
135 pub keep_alive: Value,
136}
137
138impl OllamaRuntimeSettings {
139 pub fn from_env() -> Self {
140 Self::from_env_and_overrides(None)
141 }
142
143 pub fn from_env_and_overrides(overrides: Option<&Value>) -> Self {
144 Self::from_env_overrides_and_model(overrides, None)
145 }
146
147 pub fn from_env_overrides_and_model(overrides: Option<&Value>, model: Option<&str>) -> Self {
148 Self {
149 num_ctx: num_ctx_from_overrides(overrides)
150 .or_else(num_ctx_from_env)
151 .or_else(|| num_ctx_from_model_catalog(model))
152 .unwrap_or(OLLAMA_DEFAULT_NUM_CTX),
153 keep_alive: keep_alive_from_overrides(overrides)
154 .or_else(keep_alive_from_env)
155 .unwrap_or_else(default_keep_alive_value),
156 }
157 }
158
159 pub fn warmup_body(&self, model: &str) -> Value {
160 serde_json::json!({
161 "model": model,
162 "prompt": "",
163 "stream": false,
164 "keep_alive": self.keep_alive,
165 "options": {
166 "num_ctx": self.num_ctx,
167 },
168 })
169 }
170}
171
172pub fn ollama_runtime_settings_from_env() -> OllamaRuntimeSettings {
173 OllamaRuntimeSettings::from_env()
174}
175
176pub(crate) fn ollama_unload_grace_duration_from_env() -> Duration {
177 Duration::from_millis(
178 OLLAMA_UNLOAD_GRACE_MS_ENV_KEYS
179 .iter()
180 .find_map(|key| std::env::var(key).ok().and_then(|raw| parse_grace_ms(&raw)))
181 .unwrap_or(OLLAMA_DEFAULT_UNLOAD_GRACE_MS),
182 )
183}
184
185pub async fn warm_ollama_model(model: &str, base_url: Option<&str>) -> Result<(), String> {
186 let settings = OllamaRuntimeSettings::from_env_overrides_and_model(None, Some(model));
187 warm_ollama_model_with_settings(model, base_url, &settings).await
188}
189
190pub async fn warm_ollama_model_with_settings(
191 model: &str,
192 base_url: Option<&str>,
193 settings: &OllamaRuntimeSettings,
194) -> Result<(), String> {
195 let base_url = resolve_ollama_base_url(base_url);
196 let url = format!("{}/api/generate", base_url.trim_end_matches('/'));
197 let response = crate::llm::utility_client_for_base_url(&base_url)
198 .post(url)
199 .header("Content-Type", "application/json")
200 .json(&settings.warmup_body(model))
201 .send()
202 .await
203 .map_err(|error| format!("Ollama warmup failed: {error}"))?;
204 if response.status().is_success() {
205 Ok(())
206 } else {
207 let status = response.status();
208 let body = response.text().await.unwrap_or_default();
209 Err(format!("Ollama warmup returned HTTP {status}: {body}"))
210 }
211}
212
213pub(crate) fn apply_ollama_runtime_settings(body: &mut Value, overrides: Option<&Value>) {
214 apply_non_runtime_ollama_overrides(body, overrides);
215
216 let explicit_num_ctx = num_ctx_from_overrides(overrides);
217 if explicit_num_ctx.is_some() || body.pointer("/options/num_ctx").is_none() {
218 let num_ctx = explicit_num_ctx
219 .or_else(num_ctx_from_env)
220 .or_else(|| num_ctx_from_model_catalog(body.get("model").and_then(Value::as_str)))
221 .unwrap_or(OLLAMA_DEFAULT_NUM_CTX);
222 ensure_options_object(body).insert("num_ctx".to_string(), serde_json::json!(num_ctx));
223 }
224
225 let explicit_keep_alive = keep_alive_from_overrides(overrides);
226 if let Some(keep_alive) = explicit_keep_alive
227 .or_else(|| body.get("keep_alive").cloned())
228 .or_else(keep_alive_from_env)
229 .or_else(|| Some(default_keep_alive_value()))
230 {
231 body["keep_alive"] = keep_alive;
232 }
233}
234
235fn resolve_ollama_base_url(base_url: Option<&str>) -> String {
236 base_url
237 .map(str::trim)
238 .filter(|value| !value.is_empty())
239 .map(str::to_string)
240 .or_else(|| {
241 std::env::var(OLLAMA_HOST_ENV)
242 .ok()
243 .map(|value| value.trim().to_string())
244 .filter(|value| !value.is_empty())
245 })
246 .unwrap_or_else(|| OLLAMA_DEFAULT_BASE_URL.to_string())
247}
248
249fn num_ctx_from_env() -> Option<u64> {
250 OLLAMA_NUM_CTX_ENV_KEYS
251 .iter()
252 .find_map(|key| std::env::var(key).ok().and_then(|raw| parse_num_ctx(&raw)))
253}
254
255fn num_ctx_from_model_catalog(model: Option<&str>) -> Option<u64> {
256 let model = model?.trim();
257 if model.is_empty() {
258 return None;
259 }
260 let entry = crate::llm_config::model_catalog_entry(model)?;
261 entry
262 .runtime_context_window
263 .filter(|window| *window > 0)
264 .or_else(|| (entry.context_window > 0).then_some(entry.context_window))
265}
266
267fn keep_alive_from_env() -> Option<Value> {
268 OLLAMA_KEEP_ALIVE_ENV_KEYS.iter().find_map(|key| {
269 std::env::var(key)
270 .ok()
271 .and_then(|raw| parse_keep_alive_str(&raw))
272 })
273}
274
275fn num_ctx_from_overrides(overrides: Option<&Value>) -> Option<u64> {
276 let obj = overrides?.as_object()?;
277 obj.get("num_ctx")
278 .and_then(parse_num_ctx_value)
279 .or_else(|| {
280 obj.get("options")
281 .and_then(|options| options.get("num_ctx"))
282 .and_then(parse_num_ctx_value)
283 })
284}
285
286fn keep_alive_from_overrides(overrides: Option<&Value>) -> Option<Value> {
287 overrides?
288 .as_object()?
289 .get("keep_alive")
290 .and_then(parse_keep_alive_value)
291}
292
293fn parse_num_ctx(raw: &str) -> Option<u64> {
294 raw.trim().parse::<u64>().ok().filter(|parsed| *parsed > 0)
295}
296
297fn parse_grace_ms(raw: &str) -> Option<u64> {
298 raw.trim().parse::<u64>().ok()
299}
300
301fn parse_num_ctx_value(value: &Value) -> Option<u64> {
302 match value {
303 Value::Number(number) => number.as_u64().filter(|parsed| *parsed > 0),
304 Value::String(raw) => parse_num_ctx(raw),
305 _ => None,
306 }
307}
308
309fn parse_keep_alive_value(value: &Value) -> Option<Value> {
310 match value {
311 Value::String(raw) => parse_keep_alive_str(raw),
312 Value::Number(_) => Some(value.clone()),
313 _ => None,
314 }
315}
316
317fn parse_keep_alive_str(raw: &str) -> Option<Value> {
318 let trimmed = raw.trim();
319 if trimmed.is_empty() {
320 return None;
321 }
322 Some(match trimmed.to_ascii_lowercase().as_str() {
323 "default" => default_keep_alive_value(),
324 "forever" | "infinite" | "-1" => serde_json::json!(-1),
325 _ => {
326 if let Ok(n) = trimmed.parse::<i64>() {
327 serde_json::json!(n)
328 } else {
329 serde_json::json!(trimmed)
330 }
331 }
332 })
333}
334
335fn default_keep_alive_value() -> Value {
336 serde_json::json!(OLLAMA_DEFAULT_KEEP_ALIVE)
337}
338
339fn ensure_options_object(body: &mut Value) -> &mut serde_json::Map<String, Value> {
340 if !body.get("options").is_some_and(Value::is_object) {
341 body["options"] = serde_json::json!({});
342 }
343 body["options"]
344 .as_object_mut()
345 .expect("options initialized as object")
346}
347
348fn apply_non_runtime_ollama_overrides(body: &mut Value, overrides: Option<&Value>) {
349 let Some(obj) = overrides.and_then(Value::as_object) else {
350 return;
351 };
352
353 for (key, value) in obj {
354 match key.as_str() {
355 "num_ctx" | "keep_alive" => {}
356 key if key == crate::llm::provider::FORCE_NATIVE_TOOL_SEARCH_OVERRIDE => {}
357 "options" => {
358 if let Some(options) = value.as_object() {
359 let body_options = ensure_options_object(body);
360 for (option_key, option_value) in options {
361 if option_key != "num_ctx" {
362 body_options.insert(option_key.clone(), option_value.clone());
363 }
364 }
365 }
366 }
367 _ => {
368 body[key] = value.clone();
369 }
370 }
371 }
372}
373
374pub async fn ollama_readiness(options: OllamaReadinessOptions) -> OllamaReadinessResult {
375 let base_url = options.base_url.unwrap_or_else(default_ollama_base_url);
376 let mut result = OllamaReadinessResult::probing(base_url.clone(), options.model.clone());
377
378 let tags_url = match ollama_endpoint_url(&base_url, "/api/tags") {
379 Ok(url) => url,
380 Err(message) => return result.fail("invalid_url", message),
381 };
382 result.tags_url = tags_url.clone();
383
384 let client = crate::llm::utility_client_for_base_url(&base_url);
385 let response = match client
386 .get(tags_url.clone())
387 .timeout(options.tags_timeout)
388 .send()
389 .await
390 {
391 Ok(response) => response,
392 Err(error) => {
393 return result.fail(
394 "daemon_down",
395 format!("Ollama not reachable at {tags_url}: {error}"),
396 );
397 }
398 };
399
400 let status = response.status();
401 result.http_status = Some(status.as_u16());
402 if !status.is_success() {
403 let body = response.text().await.unwrap_or_default();
404 return result.fail(
405 "bad_status",
406 format!(
407 "Ollama returned HTTP {} from /api/tags: {body}",
408 status.as_u16()
409 ),
410 );
411 }
412
413 let body: Value = match response.json().await {
414 Ok(value) => value,
415 Err(error) => {
416 return result.fail(
417 "invalid_response",
418 format!("Could not parse Ollama model list: {error}"),
419 );
420 }
421 };
422
423 let Some(models) = parse_ollama_model_names(&body) else {
424 return result.fail(
425 "invalid_response",
426 "Could not parse Ollama model list: missing models[].name".to_string(),
427 );
428 };
429 result.available_models = models.clone();
430
431 let Some(matched) = find_ollama_model_match(&models, &options.model) else {
432 let available = if models.is_empty() {
433 "(none)".to_string()
434 } else {
435 models.join(", ")
436 };
437 return result.fail(
438 "model_missing",
439 format!(
440 "Ollama model '{}' not found. Available: {available}",
441 options.model
442 ),
443 );
444 };
445 result.matched_model = Some(matched.clone());
446
447 let settings = OllamaRuntimeSettings::from_env_overrides_and_model(None, Some(&matched));
448 let keep_alive = options
449 .keep_alive
450 .clone()
451 .unwrap_or_else(|| settings.keep_alive.clone());
452 result.expected = Some(OllamaExpectedRequest {
453 num_ctx: settings.num_ctx,
454 keep_alive: keep_alive.clone(),
455 });
456 result.keep_alive = Some(keep_alive.clone());
457
458 result.message = format!("Ollama is reachable and model '{matched}' is available");
459 if options.warm {
460 let warm = ollama_warmup(
461 &base_url,
462 &matched,
463 Some(keep_alive),
464 options.warmup_timeout,
465 )
466 .await;
467 if !warm.valid {
468 result.valid = false;
469 result.status = "warmup_failed".to_string();
470 result.message = warm.message.clone();
471 } else {
472 result.message = format!("{}; {}", result.message, warm.message);
473 }
474 result.warmup = Some(warm);
475 }
476
477 if options.observe_loaded {
478 match fetch_ollama_loaded_runners(&base_url, options.tags_timeout).await {
479 Ok(runners) => {
480 if let Some(runner) = match_loaded_runner(runners, &matched) {
481 if let Some(actual) = runner.context_length {
482 if actual != settings.num_ctx {
483 result.context_drift =
484 Some(describe_context_drift(settings.num_ctx, actual));
485 }
486 }
487 result.loaded_runner = Some(runner);
488 }
489 }
490 Err(error) => {
491 result.message = format!("{}; /api/ps probe skipped: {error}", result.message);
494 }
495 }
496 }
497
498 result
499}
500
501impl OllamaReadinessResult {
502 fn probing(base_url: String, model: String) -> Self {
503 Self {
504 valid: true,
505 status: "ok".to_string(),
506 message: String::new(),
507 base_url,
508 tags_url: String::new(),
509 model,
510 matched_model: None,
511 available_models: Vec::new(),
512 http_status: None,
513 keep_alive: None,
514 warmup: None,
515 expected: None,
516 loaded_runner: None,
517 context_drift: None,
518 }
519 }
520
521 fn fail(mut self, status: &str, message: String) -> Self {
522 self.valid = false;
523 self.status = status.to_string();
524 self.message = message;
525 self
526 }
527}
528
529fn default_ollama_base_url() -> String {
530 crate::llm_config::provider_config("ollama")
531 .as_ref()
532 .map(crate::llm_config::resolve_base_url)
533 .unwrap_or_else(|| "http://localhost:11434".to_string())
534}
535
536fn ollama_endpoint_url(base_url: &str, path: &str) -> Result<String, String> {
537 let mut url = reqwest::Url::parse(base_url)
538 .map_err(|error| format!("Invalid Ollama URL '{base_url}': {error}"))?;
539 if url.host_str() == Some("localhost") {
540 url.set_host(Some("127.0.0.1"))
541 .map_err(|_| format!("Invalid Ollama URL '{base_url}': could not normalize host"))?;
542 }
543 let base_path = url.path().trim_end_matches('/');
544 let suffix = path.trim_start_matches('/');
545 let joined = if base_path.is_empty() {
546 format!("/{suffix}")
547 } else {
548 format!("{base_path}/{suffix}")
549 };
550 url.set_path(&joined);
551 url.set_query(None);
552 Ok(url.to_string())
553}
554
555fn parse_ollama_model_names(value: &serde_json::Value) -> Option<Vec<String>> {
556 let models = value.get("models")?.as_array()?;
557 Some(
558 models
559 .iter()
560 .filter_map(|model| model.get("name").and_then(|name| name.as_str()))
561 .map(str::to_string)
562 .collect(),
563 )
564}
565
566fn find_ollama_model_match(models: &[String], selected: &str) -> Option<String> {
567 models
568 .iter()
569 .find(|name| name.as_str() == selected)
570 .or_else(|| {
571 models
572 .iter()
573 .find(|name| name.strip_suffix(":latest") == Some(selected))
574 })
575 .or_else(|| models.iter().find(|name| name.starts_with(selected)))
576 .cloned()
577}
578
579pub async fn fetch_ollama_loaded_runners(
583 base_url: &str,
584 timeout: Duration,
585) -> Result<Vec<OllamaLoadedRunner>, String> {
586 let url = ollama_endpoint_url(base_url, "/api/ps")?;
587 let response = crate::llm::utility_client_for_base_url(base_url)
588 .get(&url)
589 .timeout(timeout)
590 .send()
591 .await
592 .map_err(|error| format!("Ollama /api/ps not reachable at {url}: {error}"))?;
593 if !response.status().is_success() {
594 return Err(format!(
595 "Ollama returned HTTP {} from /api/ps",
596 response.status().as_u16()
597 ));
598 }
599 let body: Value = response
600 .json()
601 .await
602 .map_err(|error| format!("Could not parse Ollama /api/ps response: {error}"))?;
603 Ok(parse_ollama_loaded_runners(&body))
604}
605
606fn parse_ollama_loaded_runners(value: &Value) -> Vec<OllamaLoadedRunner> {
607 let Some(models) = value.get("models").and_then(Value::as_array) else {
608 return Vec::new();
609 };
610 models
611 .iter()
612 .filter_map(|entry| {
613 let name = entry.get("name").and_then(Value::as_str)?.to_string();
614 let model = entry
615 .get("model")
616 .and_then(Value::as_str)
617 .map(str::to_string)
618 .unwrap_or_else(|| name.clone());
619 Some(OllamaLoadedRunner {
620 name,
621 model,
622 context_length: entry.get("context_length").and_then(Value::as_u64),
623 size_vram: entry.get("size_vram").and_then(Value::as_u64),
624 size: entry.get("size").and_then(Value::as_u64),
625 expires_at: entry
626 .get("expires_at")
627 .and_then(Value::as_str)
628 .map(str::to_string),
629 })
630 })
631 .collect()
632}
633
634fn match_loaded_runner(
635 runners: Vec<OllamaLoadedRunner>,
636 model: &str,
637) -> Option<OllamaLoadedRunner> {
638 runners
639 .into_iter()
640 .find(|runner| runner.name == model || runner.model == model)
641}
642
643fn describe_context_drift(expected: u64, actual: u64) -> String {
644 if actual > expected {
645 format!(
646 "Loaded runner context_length={actual} exceeds expected num_ctx={expected}. \
647 Ollama keeps a runner at the context it was loaded with; run \
648 `ollama stop <model>` (or wait for keep_alive to expire) and let Harn \
649 re-warm it to apply the smaller context."
650 )
651 } else {
652 format!(
653 "Loaded runner context_length={actual} is smaller than expected \
654 num_ctx={expected}. The runner was loaded at a smaller context — \
655 unload with `ollama stop <model>` and let Harn re-warm to expand."
656 )
657 }
658}
659
660async fn ollama_warmup(
661 base_url: &str,
662 model: &str,
663 keep_alive: Option<serde_json::Value>,
664 timeout: Duration,
665) -> OllamaWarmupResult {
666 let url = match ollama_endpoint_url(base_url, "/api/generate") {
667 Ok(url) => url,
668 Err(message) => {
669 return OllamaWarmupResult {
670 valid: false,
671 status: "invalid_url".to_string(),
672 message,
673 url: String::new(),
674 model: model.to_string(),
675 http_status: None,
676 };
677 }
678 };
679
680 let settings = OllamaRuntimeSettings::from_env_overrides_and_model(None, Some(model));
687 let mut body = settings.warmup_body(model);
688 if let Some(value) = keep_alive {
689 body["keep_alive"] = value;
690 }
691
692 let client = crate::llm::blocking_client_for_base_url(base_url);
693 let response = match client
694 .post(url.clone())
695 .header("Content-Type", "application/json")
696 .timeout(timeout)
697 .json(&body)
698 .send()
699 .await
700 {
701 Ok(response) => response,
702 Err(error) => {
703 return OllamaWarmupResult {
704 valid: false,
705 status: "warmup_failed".to_string(),
706 message: format!("Ollama warmup failed for model '{model}' at {url}: {error}"),
707 url,
708 model: model.to_string(),
709 http_status: None,
710 };
711 }
712 };
713
714 let status = response.status();
715 if !status.is_success() {
716 let body = response.text().await.unwrap_or_default();
717 return OllamaWarmupResult {
718 valid: false,
719 status: "warmup_failed".to_string(),
720 message: format!(
721 "Ollama warmup returned HTTP {} for model '{model}': {body}",
722 status.as_u16()
723 ),
724 url,
725 model: model.to_string(),
726 http_status: Some(status.as_u16()),
727 };
728 }
729
730 let body: serde_json::Value = response.json().await.unwrap_or_default();
731 if let Some(error) = body.get("error").and_then(|error| error.as_str()) {
732 return OllamaWarmupResult {
733 valid: false,
734 status: "warmup_failed".to_string(),
735 message: format!("Ollama warmup failed for model '{model}': {error}"),
736 url,
737 model: model.to_string(),
738 http_status: Some(status.as_u16()),
739 };
740 }
741
742 OllamaWarmupResult {
743 valid: true,
744 status: "ok".to_string(),
745 message: format!("Ollama model '{model}' warmed"),
746 url,
747 model: model.to_string(),
748 http_status: Some(status.as_u16()),
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755 use crate::http::framing::{http_content_length_from_header_lines, TEST_HTTP_MAX_BODY_BYTES};
756 use crate::llm::env_guard;
757 use std::io::{Read, Write};
758 use std::net::TcpListener;
759 use std::sync::{Arc, Mutex};
760 use std::time::Duration;
761
762 struct ScopedEnvVar {
763 key: &'static str,
764 previous: Option<String>,
765 }
766
767 impl ScopedEnvVar {
768 fn set(key: &'static str, value: &str) -> Self {
769 let previous = std::env::var(key).ok();
770 unsafe {
771 std::env::set_var(key, value);
772 }
773 Self { key, previous }
774 }
775
776 fn remove(key: &'static str) -> Self {
777 let previous = std::env::var(key).ok();
778 unsafe {
779 std::env::remove_var(key);
780 }
781 Self { key, previous }
782 }
783 }
784
785 impl Drop for ScopedEnvVar {
786 fn drop(&mut self) {
787 match &self.previous {
788 Some(value) => unsafe { std::env::set_var(self.key, value) },
789 None => unsafe { std::env::remove_var(self.key) },
790 }
791 }
792 }
793
794 #[test]
795 fn runtime_settings_use_harn_env_before_ollama_env() {
796 let _guard = env_guard();
797 let _env = [
798 ScopedEnvVar::set("HARN_OLLAMA_NUM_CTX", "131072"),
799 ScopedEnvVar::set("OLLAMA_CONTEXT_LENGTH", "32768"),
800 ScopedEnvVar::set("HARN_OLLAMA_KEEP_ALIVE", "forever"),
801 ScopedEnvVar::set("OLLAMA_KEEP_ALIVE", "5m"),
802 ];
803 let settings = OllamaRuntimeSettings::from_env();
804 assert_eq!(settings.num_ctx, 131072);
805 assert_eq!(settings.keep_alive, serde_json::json!(-1));
806 }
807
808 #[test]
809 fn runtime_settings_apply_harn_defaults() {
810 let _guard = env_guard();
811 let _env = [
812 ScopedEnvVar::remove("HARN_OLLAMA_NUM_CTX"),
813 ScopedEnvVar::remove("OLLAMA_CONTEXT_LENGTH"),
814 ScopedEnvVar::remove("OLLAMA_NUM_CTX"),
815 ScopedEnvVar::remove("HARN_OLLAMA_KEEP_ALIVE"),
816 ScopedEnvVar::remove("OLLAMA_KEEP_ALIVE"),
817 ];
818 let settings = OllamaRuntimeSettings::from_env();
819 assert_eq!(settings.num_ctx, OLLAMA_DEFAULT_NUM_CTX);
820 assert_eq!(settings.keep_alive, serde_json::json!("30m"));
821 }
822
823 #[test]
824 fn runtime_settings_use_catalog_context_after_env_and_overrides() {
825 let _guard = env_guard();
826 let _env = [
827 ScopedEnvVar::remove("HARN_OLLAMA_NUM_CTX"),
828 ScopedEnvVar::remove("OLLAMA_CONTEXT_LENGTH"),
829 ScopedEnvVar::remove("OLLAMA_NUM_CTX"),
830 ScopedEnvVar::remove("HARN_OLLAMA_KEEP_ALIVE"),
831 ScopedEnvVar::remove("OLLAMA_KEEP_ALIVE"),
832 ];
833 crate::llm_config::clear_user_overrides();
834 let mut overlay = crate::llm_config::ProvidersConfig::default();
835 overlay.models.insert(
836 "qwen-test".to_string(),
837 crate::llm_config::ModelDef {
838 name: "Qwen Test".to_string(),
839 blurb: None,
840 provider: "ollama".to_string(),
841 context_window: 100_000,
842 logical_model: None,
843 equivalence_group: None,
844 served_variant: None,
845 wire_model: None,
846 api_dialect: None,
847 rate_limits: None,
848 performance: None,
849 architecture: None,
850 local_memory: None,
851 runtime_context_window: None,
852 stream_timeout: None,
853 capabilities: vec![],
854 pricing: None,
855 deprecated: false,
856 deprecation_note: None,
857 superseded_by: None,
858 serving_tiers: Vec::new(),
859 quality_tags: Vec::new(),
860 availability: crate::llm_config::ModelAvailability::default(),
861 tier: None,
862 open_weight: None,
863 strengths: Vec::new(),
864 benchmarks: std::collections::BTreeMap::new(),
865 family: None,
866 lineage: None,
867 complementary_with: Vec::new(),
868 avoid_as_reviewer_for: Vec::new(),
869 },
870 );
871 crate::llm_config::set_user_overrides(Some(overlay));
872
873 let settings = OllamaRuntimeSettings::from_env_overrides_and_model(None, Some("qwen-test"));
874 assert_eq!(settings.num_ctx, 100_000);
875
876 let env = ScopedEnvVar::set("HARN_OLLAMA_NUM_CTX", "65536");
877 let settings = OllamaRuntimeSettings::from_env_overrides_and_model(None, Some("qwen-test"));
878 assert_eq!(settings.num_ctx, 65_536);
879 drop(env);
880
881 let overrides = serde_json::json!({"num_ctx": 8192});
882 let settings = OllamaRuntimeSettings::from_env_overrides_and_model(
883 Some(&overrides),
884 Some("qwen-test"),
885 );
886 assert_eq!(settings.num_ctx, 8_192);
887
888 crate::llm_config::clear_user_overrides();
889 }
890
891 #[test]
892 fn provider_overrides_beat_env_and_normalize_keep_alive() {
893 let _guard = env_guard();
894 let _env = [
895 ScopedEnvVar::set("HARN_OLLAMA_NUM_CTX", "131072"),
896 ScopedEnvVar::set("HARN_OLLAMA_KEEP_ALIVE", "5m"),
897 ];
898 let overrides = serde_json::json!({
899 "num_ctx": "65536",
900 "keep_alive": "infinite",
901 });
902 let settings = OllamaRuntimeSettings::from_env_and_overrides(Some(&overrides));
903 assert_eq!(settings.num_ctx, 65536);
904 assert_eq!(settings.keep_alive, serde_json::json!(-1));
905 }
906
907 #[test]
908 fn apply_runtime_settings_maps_ollama_overrides_to_native_shape() {
909 let _guard = env_guard();
910 let _env = [
911 ScopedEnvVar::remove("HARN_OLLAMA_NUM_CTX"),
912 ScopedEnvVar::remove("OLLAMA_CONTEXT_LENGTH"),
913 ScopedEnvVar::remove("OLLAMA_NUM_CTX"),
914 ScopedEnvVar::remove("HARN_OLLAMA_KEEP_ALIVE"),
915 ScopedEnvVar::remove("OLLAMA_KEEP_ALIVE"),
916 ];
917 let mut body = serde_json::json!({
918 "model": "qwen",
919 "options": {"temperature": 0.1}
920 });
921 let overrides = serde_json::json!({
922 "num_ctx": 65536,
923 "keep_alive": "default",
924 "options": {"top_k": 20, "num_ctx": 999},
925 "think": true,
926 });
927 apply_ollama_runtime_settings(&mut body, Some(&overrides));
928 assert_eq!(body["options"]["num_ctx"], serde_json::json!(65536));
929 assert_eq!(body["options"]["top_k"], serde_json::json!(20));
930 assert_eq!(body["options"]["temperature"], serde_json::json!(0.1));
931 assert_eq!(body["keep_alive"], serde_json::json!("30m"));
932 assert_eq!(body["think"], serde_json::json!(true));
933 assert!(body.get("num_ctx").is_none());
934 }
935
936 #[test]
937 fn apply_runtime_settings_uses_catalog_context_when_body_has_model() {
938 let _guard = env_guard();
939 let _env = [
940 ScopedEnvVar::remove("HARN_OLLAMA_NUM_CTX"),
941 ScopedEnvVar::remove("OLLAMA_CONTEXT_LENGTH"),
942 ScopedEnvVar::remove("OLLAMA_NUM_CTX"),
943 ScopedEnvVar::remove("HARN_OLLAMA_KEEP_ALIVE"),
944 ScopedEnvVar::remove("OLLAMA_KEEP_ALIVE"),
945 ];
946 crate::llm_config::clear_user_overrides();
947 let mut overlay = crate::llm_config::ProvidersConfig::default();
948 overlay.models.insert(
949 "qwen-test".to_string(),
950 crate::llm_config::ModelDef {
951 name: "Qwen Test".to_string(),
952 blurb: None,
953 provider: "ollama".to_string(),
954 context_window: 100_000,
955 logical_model: None,
956 equivalence_group: None,
957 served_variant: None,
958 wire_model: None,
959 api_dialect: None,
960 rate_limits: None,
961 performance: None,
962 architecture: None,
963 local_memory: None,
964 runtime_context_window: Some(32_768),
965 stream_timeout: None,
966 capabilities: vec![],
967 pricing: None,
968 deprecated: false,
969 deprecation_note: None,
970 superseded_by: None,
971 serving_tiers: Vec::new(),
972 quality_tags: Vec::new(),
973 availability: crate::llm_config::ModelAvailability::default(),
974 tier: None,
975 open_weight: None,
976 strengths: Vec::new(),
977 benchmarks: std::collections::BTreeMap::new(),
978 family: None,
979 lineage: None,
980 complementary_with: Vec::new(),
981 avoid_as_reviewer_for: Vec::new(),
982 },
983 );
984 crate::llm_config::set_user_overrides(Some(overlay));
985
986 let mut body = serde_json::json!({
987 "model": "qwen-test",
988 "options": {"temperature": 0.1}
989 });
990 apply_ollama_runtime_settings(&mut body, None);
991 assert_eq!(body["options"]["num_ctx"], serde_json::json!(32768));
992 assert_eq!(body["options"]["temperature"], serde_json::json!(0.1));
993
994 crate::llm_config::clear_user_overrides();
995 }
996
997 #[test]
998 fn ollama_keep_alive_normalization_handles_default_and_numbers() {
999 assert_eq!(
1000 normalize_ollama_keep_alive("default"),
1001 Some(serde_json::json!("30m"))
1002 );
1003 assert_eq!(
1004 normalize_ollama_keep_alive("forever"),
1005 Some(serde_json::json!(-1))
1006 );
1007 assert_eq!(
1008 normalize_ollama_keep_alive("120"),
1009 Some(serde_json::json!(120))
1010 );
1011 assert_eq!(
1012 normalize_ollama_keep_alive("10m"),
1013 Some(serde_json::json!("10m"))
1014 );
1015 assert_eq!(normalize_ollama_keep_alive(" "), None);
1016 }
1017
1018 fn readiness_options(model: &str, base_url: String) -> OllamaReadinessOptions {
1019 OllamaReadinessOptions {
1020 model: model.to_string(),
1021 base_url: Some(base_url),
1022 warm: false,
1023 keep_alive: None,
1024 tags_timeout: Duration::from_secs(2),
1025 warmup_timeout: Duration::from_secs(2),
1026 observe_loaded: false,
1027 }
1028 }
1029
1030 #[test]
1031 fn ollama_readiness_verifies_model_and_warms_matched_tag() {
1032 let _guard = env_guard();
1033 let _env = [
1034 ScopedEnvVar::set("HARN_OLLAMA_NUM_CTX", "65536"),
1035 ScopedEnvVar::remove("OLLAMA_CONTEXT_LENGTH"),
1036 ScopedEnvVar::remove("OLLAMA_NUM_CTX"),
1037 ScopedEnvVar::remove("HARN_OLLAMA_KEEP_ALIVE"),
1038 ScopedEnvVar::remove("OLLAMA_KEEP_ALIVE"),
1039 ];
1040 let captured = Arc::new(Mutex::new(Vec::new()));
1041 let (addr, server) = spawn_stub(
1042 vec![
1043 (
1044 200,
1045 r#"{"models":[{"name":"qwen3:latest"},{"name":"llama3.2:latest"}]}"#,
1046 ),
1047 (200, r#"{"response":"","done":true}"#),
1048 ],
1049 captured.clone(),
1050 );
1051
1052 let result = tokio::runtime::Runtime::new()
1053 .expect("runtime")
1054 .block_on(ollama_readiness(OllamaReadinessOptions {
1055 warm: true,
1056 keep_alive: Some(serde_json::json!(-1)),
1057 ..readiness_options("qwen3", format!("http://{addr}"))
1058 }));
1059
1060 server.join().expect("stub server");
1061 assert!(result.valid, "result was: {result:?}");
1062 assert_eq!(result.status, "ok");
1063 assert_eq!(result.matched_model.as_deref(), Some("qwen3:latest"));
1064 assert!(result.warmup.as_ref().is_some_and(|warm| warm.valid));
1065 let expected = result.expected.as_ref().expect("expected request");
1066 assert_eq!(expected.num_ctx, 65_536);
1067 assert_eq!(expected.keep_alive, serde_json::json!(-1));
1068
1069 let requests = captured.lock().expect("captured requests");
1070 assert!(requests[0].starts_with("GET /api/tags "));
1071 assert!(requests[1].starts_with("POST /api/generate "));
1072 let body = requests[1].split("\r\n\r\n").nth(1).unwrap_or("");
1073 let json: serde_json::Value = serde_json::from_str(body).expect("warmup body");
1074 assert_eq!(json["model"], "qwen3:latest");
1075 assert_eq!(json["prompt"], "");
1076 assert_eq!(json["stream"], false);
1077 assert_eq!(json["keep_alive"], -1);
1078 assert_eq!(
1079 json["options"]["num_ctx"], 65_536,
1080 "warmup must inject num_ctx so Ollama loads the runner at the requested context — see issue #1600"
1081 );
1082 }
1083
1084 #[test]
1085 fn ollama_readiness_reports_missing_model_with_available_tags() {
1086 let captured = Arc::new(Mutex::new(Vec::new()));
1087 let (addr, server) = spawn_stub(
1088 vec![(200, r#"{"models":[{"name":"llama3.2:latest"}]}"#)],
1089 captured,
1090 );
1091
1092 let result = tokio::runtime::Runtime::new()
1093 .expect("runtime")
1094 .block_on(ollama_readiness(readiness_options(
1095 "qwen3",
1096 format!("http://{addr}"),
1097 )));
1098
1099 server.join().expect("stub server");
1100 assert!(!result.valid);
1101 assert_eq!(result.status, "model_missing");
1102 assert_eq!(result.available_models, vec!["llama3.2:latest"]);
1103 assert!(result.message.contains("qwen3"));
1104 }
1105
1106 #[test]
1107 fn ollama_readiness_observes_loaded_runner_and_reports_no_drift() {
1108 let _guard = env_guard();
1109 let _env = [
1110 ScopedEnvVar::set("HARN_OLLAMA_NUM_CTX", "32768"),
1111 ScopedEnvVar::remove("OLLAMA_CONTEXT_LENGTH"),
1112 ScopedEnvVar::remove("OLLAMA_NUM_CTX"),
1113 ScopedEnvVar::remove("HARN_OLLAMA_KEEP_ALIVE"),
1114 ScopedEnvVar::remove("OLLAMA_KEEP_ALIVE"),
1115 ];
1116 let captured = Arc::new(Mutex::new(Vec::new()));
1117 let (addr, server) = spawn_stub(
1118 vec![
1119 (200, r#"{"models":[{"name":"qwen3:latest"}]}"#),
1120 (
1121 200,
1122 r#"{"models":[{"name":"qwen3:latest","model":"qwen3:latest","context_length":32768,"size_vram":1234,"size":4321,"expires_at":"2026-05-13T12:00:00Z"}]}"#,
1123 ),
1124 ],
1125 captured.clone(),
1126 );
1127
1128 let result = tokio::runtime::Runtime::new()
1129 .expect("runtime")
1130 .block_on(ollama_readiness(OllamaReadinessOptions {
1131 observe_loaded: true,
1132 ..readiness_options("qwen3", format!("http://{addr}"))
1133 }));
1134
1135 server.join().expect("stub server");
1136 assert!(result.valid, "result was: {result:?}");
1137 let runner = result.loaded_runner.expect("loaded runner present");
1138 assert_eq!(runner.context_length, Some(32_768));
1139 assert_eq!(runner.size_vram, Some(1234));
1140 assert!(
1141 result.context_drift.is_none(),
1142 "no drift expected; got {:?}",
1143 result.context_drift
1144 );
1145
1146 let requests = captured.lock().expect("captured requests");
1147 assert!(requests[0].starts_with("GET /api/tags "));
1148 assert!(requests[1].starts_with("GET /api/ps "));
1149 }
1150
1151 #[test]
1152 fn ollama_readiness_flags_context_drift_when_loaded_exceeds_expected() {
1153 let _guard = env_guard();
1154 let _env = [
1155 ScopedEnvVar::set("HARN_OLLAMA_NUM_CTX", "32768"),
1156 ScopedEnvVar::remove("OLLAMA_CONTEXT_LENGTH"),
1157 ScopedEnvVar::remove("OLLAMA_NUM_CTX"),
1158 ScopedEnvVar::remove("HARN_OLLAMA_KEEP_ALIVE"),
1159 ScopedEnvVar::remove("OLLAMA_KEEP_ALIVE"),
1160 ];
1161 let (addr, server) = spawn_stub(
1162 vec![
1163 (200, r#"{"models":[{"name":"devstral-small-2:24b"}]}"#),
1164 (
1165 200,
1166 r#"{"models":[{"name":"devstral-small-2:24b","model":"devstral-small-2:24b","context_length":262144}]}"#,
1167 ),
1168 ],
1169 Arc::new(Mutex::new(Vec::new())),
1170 );
1171
1172 let result = tokio::runtime::Runtime::new()
1173 .expect("runtime")
1174 .block_on(ollama_readiness(OllamaReadinessOptions {
1175 observe_loaded: true,
1176 ..readiness_options("devstral-small-2:24b", format!("http://{addr}"))
1177 }));
1178
1179 server.join().expect("stub server");
1180 assert!(result.valid, "result was: {result:?}");
1181 let drift = result.context_drift.expect("drift expected");
1182 assert!(drift.contains("262144"), "drift message: {drift}");
1183 assert!(drift.contains("32768"), "drift message: {drift}");
1184 assert!(
1185 drift.contains("ollama stop"),
1186 "drift message must teach the user how to recover: {drift}"
1187 );
1188 }
1189
1190 #[test]
1191 fn ollama_readiness_uses_alias_resolved_runtime_settings() {
1192 let _guard = env_guard();
1193 let _env = [
1194 ScopedEnvVar::remove("HARN_OLLAMA_NUM_CTX"),
1195 ScopedEnvVar::remove("OLLAMA_CONTEXT_LENGTH"),
1196 ScopedEnvVar::remove("OLLAMA_NUM_CTX"),
1197 ScopedEnvVar::remove("HARN_OLLAMA_KEEP_ALIVE"),
1198 ScopedEnvVar::remove("OLLAMA_KEEP_ALIVE"),
1199 ];
1200 crate::llm_config::clear_user_overrides();
1201 let mut overlay = crate::llm_config::ProvidersConfig::default();
1202 overlay.aliases.insert(
1203 "devstral-small-2".to_string(),
1204 crate::llm_config::AliasDef {
1205 id: "devstral-small-2:24b".to_string(),
1206 provider: "ollama".to_string(),
1207 tool_format: None,
1208 },
1209 );
1210 overlay.models.insert(
1211 "devstral-small-2:24b".to_string(),
1212 crate::llm_config::ModelDef {
1213 name: "Devstral Small 2 24B".to_string(),
1214 blurb: None,
1215 provider: "ollama".to_string(),
1216 context_window: 262_144,
1217 logical_model: None,
1218 equivalence_group: None,
1219 served_variant: None,
1220 wire_model: None,
1221 api_dialect: None,
1222 rate_limits: None,
1223 performance: None,
1224 architecture: None,
1225 local_memory: None,
1226 runtime_context_window: Some(98_304),
1227 stream_timeout: None,
1228 capabilities: vec![],
1229 pricing: None,
1230 deprecated: false,
1231 deprecation_note: None,
1232 superseded_by: None,
1233 serving_tiers: Vec::new(),
1234 quality_tags: Vec::new(),
1235 availability: crate::llm_config::ModelAvailability::default(),
1236 tier: None,
1237 open_weight: None,
1238 strengths: Vec::new(),
1239 benchmarks: std::collections::BTreeMap::new(),
1240 family: None,
1241 lineage: None,
1242 complementary_with: Vec::new(),
1243 avoid_as_reviewer_for: Vec::new(),
1244 },
1245 );
1246 crate::llm_config::set_user_overrides(Some(overlay));
1247
1248 let (resolved, _) = crate::llm_config::resolve_model("devstral-small-2");
1249 assert_eq!(resolved, "devstral-small-2:24b");
1250
1251 let captured = Arc::new(Mutex::new(Vec::new()));
1252 let (addr, server) = spawn_stub(
1253 vec![
1254 (200, r#"{"models":[{"name":"devstral-small-2:24b"}]}"#),
1255 (200, r#"{"response":"","done":true}"#),
1256 ],
1257 captured.clone(),
1258 );
1259
1260 let result = tokio::runtime::Runtime::new()
1261 .expect("runtime")
1262 .block_on(ollama_readiness(OllamaReadinessOptions {
1263 warm: true,
1264 ..readiness_options(&resolved, format!("http://{addr}"))
1265 }));
1266
1267 server.join().expect("stub server");
1268 crate::llm_config::clear_user_overrides();
1269
1270 assert!(result.valid, "result was: {result:?}");
1271 let expected = result.expected.expect("expected request populated");
1272 assert_eq!(
1273 expected.num_ctx, 98_304,
1274 "alias-resolved model must pull runtime_context_window from the catalog"
1275 );
1276
1277 let requests = captured.lock().expect("captured requests");
1278 let warmup_body = requests[1].split("\r\n\r\n").nth(1).unwrap_or("");
1279 let json: serde_json::Value = serde_json::from_str(warmup_body).expect("warmup body");
1280 assert_eq!(json["options"]["num_ctx"], 98_304);
1281 }
1282
1283 #[test]
1284 fn fetch_ollama_loaded_runners_parses_optional_fields() {
1285 let captured = Arc::new(Mutex::new(Vec::new()));
1286 let (addr, server) = spawn_stub(
1287 vec![(
1288 200,
1289 r#"{"models":[{"name":"a:latest","model":"a:latest"},{"name":"b:latest","model":"b:latest","context_length":8192,"size_vram":42,"expires_at":"now"}]}"#,
1290 )],
1291 captured,
1292 );
1293
1294 let runners = tokio::runtime::Runtime::new()
1295 .expect("runtime")
1296 .block_on(fetch_ollama_loaded_runners(
1297 &format!("http://{addr}"),
1298 Duration::from_secs(2),
1299 ))
1300 .expect("ps response parses");
1301 server.join().expect("stub server");
1302
1303 assert_eq!(runners.len(), 2);
1304 assert_eq!(runners[0].name, "a:latest");
1305 assert!(runners[0].context_length.is_none());
1306 assert_eq!(runners[1].context_length, Some(8192));
1307 assert_eq!(runners[1].size_vram, Some(42));
1308 assert_eq!(runners[1].expires_at.as_deref(), Some("now"));
1309 }
1310
1311 fn spawn_stub(
1312 responses: Vec<(u16, &'static str)>,
1313 captured: Arc<Mutex<Vec<String>>>,
1314 ) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
1315 let listener = TcpListener::bind("127.0.0.1:0").expect("bind ollama stub");
1316 let addr = listener.local_addr().expect("stub addr");
1317 let handle = std::thread::spawn(move || {
1318 for (status, body) in responses {
1319 let (mut stream, _) = listener.accept().expect("accept request");
1320 stream
1321 .set_read_timeout(Some(Duration::from_secs(2)))
1322 .expect("read timeout");
1323 let request = read_http_request(&mut stream);
1324 captured.lock().expect("captured").push(request);
1325 let reason = if status == 200 { "OK" } else { "ERROR" };
1326 let response = format!(
1327 "HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
1328 body.len()
1329 );
1330 stream
1331 .write_all(response.as_bytes())
1332 .expect("write response");
1333 }
1334 });
1335 (addr, handle)
1336 }
1337
1338 fn read_http_request(stream: &mut std::net::TcpStream) -> String {
1339 let mut data = Vec::new();
1340 let mut buf = [0_u8; 512];
1341 loop {
1342 let n = stream.read(&mut buf).expect("read request");
1343 if n == 0 {
1344 break;
1345 }
1346 data.extend_from_slice(&buf[..n]);
1347 let text = String::from_utf8_lossy(&data);
1348 if let Some(header_end) = text.find("\r\n\r\n") {
1349 let headers = &text[..header_end];
1350 let content_length = match http_content_length_from_header_lines(
1351 headers.lines(),
1352 TEST_HTTP_MAX_BODY_BYTES,
1353 ) {
1354 Ok(content_length) => content_length,
1355 Err(_) => break,
1356 };
1357 let Some(body_end) = header_end
1358 .checked_add(4)
1359 .and_then(|body_start| body_start.checked_add(content_length))
1360 else {
1361 break;
1362 };
1363 if data.len() >= body_end {
1364 break;
1365 }
1366 }
1367 }
1368 String::from_utf8(data).expect("utf8 request")
1369 }
1370}