Skip to main content

harn_vm/llm/api/
ollama.rs

1//! Ollama-specific runtime settings consumed by chat, completion, and
2//! model warmup paths.
3
4use 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    /// Runtime settings Harn would inject into a request body for this
38    /// model, computed from env, provider overrides, and the catalog.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub expected: Option<OllamaExpectedRequest>,
41    /// The matched runner reported by `/api/ps`, if the model is currently
42    /// loaded. The `context_length` field is the effective context the
43    /// loaded runner was started with — this is what `ollama ps` prints.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub loaded_runner: Option<OllamaLoadedRunner>,
46    /// Set when the loaded runner's `context_length` differs from the
47    /// `expected.num_ctx` Harn would request. Explains how to reload.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub context_drift: Option<String>,
50}
51
52/// The runtime knobs Harn would attach to chat/completion/warmup
53/// requests for a given model. Surfaced by readiness so callers can
54/// compare it against what `/api/ps` says is actually loaded.
55#[derive(Debug, Clone, Serialize)]
56pub struct OllamaExpectedRequest {
57    pub num_ctx: u64,
58    pub keep_alive: Value,
59}
60
61/// One entry from `GET /api/ps` — a model the Ollama daemon currently
62/// has loaded into memory. `context_length` is the effective context
63/// the runner was started with and is fixed for the lifetime of the
64/// loaded process; reloading is required to change it.
65#[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    /// Hit `/api/ps` and report any drift between the loaded runner's
88    /// `context_length` and the `num_ctx` Harn would request.
89    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
106/// Public wrapper around the internal keep-alive parser, used by callers
107/// (CLI flags, host bridges) that want the same normalization Harn applies
108/// to environment overrides.
109pub 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                // /api/ps is best-effort; surface the failure in the
492                // message but don't fail the overall readiness check.
493                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
579/// Fetch the list of currently-loaded runners from Ollama's `/api/ps`
580/// endpoint. Returns an empty list when no models are loaded; returns an
581/// error when the daemon is unreachable or the response is malformed.
582pub 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    // Derive the warmup body from runtime settings so num_ctx is loaded
681    // into the runner the same way chat/completion requests would. Without
682    // it, Ollama loads the model at its Modelfile-declared maximum
683    // context, and a subsequent chat request asking for a smaller
684    // num_ctx cannot shrink an already-loaded runner — see the
685    // "Effective vs. loaded context" section of docs/src/llm/providers.md.
686    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}