Skip to main content

mermaid_model/models/adapters/
ollama.rs

1//! Ollama model adapter
2//!
3//! Provides unified interface to Ollama (both local and cloud) with connection pooling,
4//! health monitoring, and zero-unwrap error handling.
5
6use async_trait::async_trait;
7use futures::StreamExt;
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::constants::MAX_RESPONSE_CHARS;
15use crate::models::ModelCapabilities;
16use crate::models::adapters::ollama_sizing::ModelDims;
17use crate::models::config::{BackendConfig, ModelConfig};
18use crate::models::error::{BackendError, ModelError, Result};
19use crate::models::reasoning::{ReasoningChunk, ReasoningLevel};
20use crate::models::stream::{StreamCallback, StreamEvent};
21use crate::models::traits::Model;
22use crate::models::types::{ChatMessage, FinishReason, MessageRole, ModelResponse, TokenUsage};
23use crate::utils::drain_complete_lines;
24
25/// Marker appended to `content` and `thinking` once the per-stream size cap
26/// is hit. Subsequent chunks are silently dropped so a runaway model can't
27/// exhaust memory in non-interactive mode or sub-agents (the TUI applies its
28/// own cap on the buffered response, but the adapter is the only line of
29/// defense for non-TUI callers).
30const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
31
32/// Mutable accumulators for stream processing, grouped to reduce parameter count.
33struct StreamAccumulator {
34    content: String,
35    thinking: String,
36    tool_calls: Vec<crate::models::ToolCall>,
37    /// Suppress `StreamEvent::Reasoning` emission to the typed callback.
38    /// The accumulator still records `thinking` so `ModelResponse.thinking`
39    /// stays populated for backward-compat callers — only the user-visible
40    /// stream is gated. Mirrors Ollama's `--hidethinking` semantics.
41    hide_reasoning_trace: bool,
42    prompt_tokens: usize,
43    completion_tokens: usize,
44    /// Set once the terminal `done` chunk reports real eval counts. A stream cut
45    /// before `done` (or a `done` without counts) leaves this `false`, so
46    /// `usage()` returns `None` rather than a zero `TokenUsage` that would reset
47    /// the reducer's context gauge (F54, mirrors gemini's `saw_usage` / #125).
48    saw_usage: bool,
49    /// Ollama's `done_reason` from the terminal chunk, mapped to a
50    /// `FinishReason` for the final `ModelResponse` instead of `None` (#13).
51    done_reason: Option<String>,
52    /// True once Ollama's terminal `done` chunk has been observed (F56). The
53    /// `done` chunk is the authoritative terminal frame; a stream that ends
54    /// without it was dropped mid-response. Tracked SEPARATELY from `done_reason`
55    /// on purpose: the context-full truncation arrives as a real `done` chunk
56    /// (with `done_reason: "length"`), so keying the abnormal-close check off
57    /// `saw_done` keeps that legitimate `Ok + FinishReason::Length` truncation
58    /// from being misclassified as a stream error.
59    saw_done: bool,
60    /// True once `content` OR `thinking` has been truncated to the size cap.
61    /// Once set, further chunks are silently dropped — both from the
62    /// accumulator AND from typed-event emission. Prevents a runaway model
63    /// from filling memory in non-interactive mode and sub-agents.
64    truncated: bool,
65}
66
67impl StreamAccumulator {
68    /// Final token usage, or `None` when the stream never reported eval counts
69    /// (e.g. it was cut before Ollama's terminal `done` chunk). Returning `None`
70    /// rather than a zero `TokenUsage` keeps the reducer's context gauge from
71    /// being reset to zero (F54, mirrors gemini's `saw_usage` guard / #125).
72    fn usage(&self) -> Option<TokenUsage> {
73        self.saw_usage
74            .then(|| TokenUsage::provider(self.prompt_tokens, self.completion_tokens))
75    }
76
77    /// F56: whether the stream closed abnormally — it ended before Ollama's
78    /// terminal `done` chunk was ever observed (a connection dropped
79    /// mid-response). Returning a clean `Ok` here would be indistinguishable
80    /// from a real completion. Keyed off `saw_done` (the terminal frame), NOT
81    /// `done_reason`, so a context-full truncation — a real `done` chunk whose
82    /// `done_reason` is `"length"`, surfaced as `Ok + FinishReason::Length` for
83    /// the runtime's compact-and-continue — is NOT misclassified as an error.
84    fn closed_abnormally(&self) -> bool {
85        !self.saw_done
86    }
87}
88
89/// Append `chunk` to `buf`, char-boundary-safe truncation at `cap` bytes.
90/// Sets `*truncated` once tripped; subsequent calls become no-ops.
91fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
92    if *truncated {
93        return;
94    }
95    buf.push_str(chunk);
96    if buf.len() > cap {
97        let end = buf.floor_char_boundary(cap);
98        buf.truncate(end);
99        buf.push_str(TRUNCATION_MARKER);
100        *truncated = true;
101    }
102}
103
104/// Brings a dead *local* model server back up.
105///
106/// Injected rather than called directly. Spawning a process is not a wire
107/// adapter's job, and while the adapter reached into `ollama::ensure_running`
108/// itself, the recovery was invisible from the call site — which is how a
109/// connection-refused retry sat inside a read-only listing path unnoticed (see
110/// [`crate::models::retry::retry_transient_http_no_connect_retry`]).
111///
112/// The provider layer supplies an implementation when the user's config allows
113/// autostart. Enumeration verbs simply pass `None`, and that absence *is* the
114/// read-only guarantee: observing state cannot start a server it has no way to
115/// start.
116#[async_trait]
117pub trait LocalServerRecovery: Send + Sync {
118    /// `Ok(())` once the server is up. `Err(Some(hint))` carries an actionable
119    /// next step to append to the connection error; `Err(None)` means there is
120    /// nothing useful to add.
121    async fn ensure_running(
122        &self,
123        base_url: &str,
124        notify: Option<&(dyn for<'a> Fn(&'a str) + Sync)>,
125    ) -> std::result::Result<(), Option<String>>;
126}
127
128/// Ollama model adapter
129pub struct OllamaAdapter {
130    client: Client,
131    base_url: String,
132    model_name: String,
133    capabilities: ModelCapabilities,
134    /// Whether the model advertises the `thinking` capability via `/api/show`.
135    /// Probed lazily on the first chat and cached, so `new` stays network-free.
136    /// `None` until resolved; recent Ollama 400s a `think` field sent to a
137    /// non-thinking model, so the send is gated on this (#122).
138    thinking_cap: tokio::sync::OnceCell<bool>,
139    /// Whether the model advertises the `vision` capability via `/api/show`.
140    /// Probed lazily and cached like `thinking_cap`. Drives the no-vision-model
141    /// warning (sending an image to a model that can't see it is silently
142    /// ignored by Ollama); never gates the send.
143    vision_cap: tokio::sync::OnceCell<bool>,
144    /// How to revive a dead local server when a request is refused. `Some`
145    /// only when the caller opted in — `BackendConfig::ollama_autostart` is
146    /// what the provider layer consults before attaching one. The user should
147    /// never have to leave mermaid to run `ollama serve`, but the adapter no
148    /// longer decides that for itself.
149    recovery: Option<Arc<dyn LocalServerRecovery>>,
150    /// Fallback surface for the autostart notice on paths that carry no
151    /// stream callback (`list_models` — the startup preflight and the CLI
152    /// list verbs). Console constructors attach an stderr printer via
153    /// [`OllamaAdapter::with_status_notify`]; the chat path's stream
154    /// callback takes precedence. `None` (the default) keeps recovery
155    /// silent, which is the safe choice anywhere a TUI might own the
156    /// screen.
157    status_notify: Option<StreamCallback>,
158}
159
160/// True if this model takes the gpt-oss `think: "low"|"medium"|"high"` string
161/// enum instead of Ollama's usual `think: bool` — per the capability catalog
162/// (case-insensitive prefix, so tagged variants like `gpt-oss:20b` and
163/// `gpt-oss:120b-cloud` all route correctly).
164fn uses_effort_string_think(model_name: &str) -> bool {
165    matches!(
166        crate::models::catalog::lookup(model_name).thinking,
167        crate::models::catalog::ThinkingShape::OllamaEffortString
168    )
169}
170
171/// Render the `think` field for an Ollama request.
172///
173/// Ollama accepts two incompatible shapes for this field:
174/// - Most models (qwen3, deepseek-r1, kimi-k2-thinking, ...) take `think: bool`.
175/// - **gpt-oss** models take `think: "low"|"medium"|"high"` (string enum).
176///
177/// Sending a bool to gpt-oss silently uses the default effort; sending a
178/// string to non-gpt-oss models 400s. This dispatch picks the right shape
179/// by inspecting the model name.
180/// `supports_thinking` is the model's advertised `thinking` capability (probed
181/// lazily); `None` from the call site means "send as before" (unknown). Returns
182/// `None` when no `think` field should be sent at all — a non-thinking model
183/// 400s on a stray `think` (#122).
184fn think_for_ollama(
185    model_name: &str,
186    level: ReasoningLevel,
187    supports_thinking: bool,
188) -> Option<serde_json::Value> {
189    if uses_effort_string_think(model_name) {
190        let effort = match level {
191            // gpt-oss can't truly disable thinking. `None` collapses to
192            // `"low"` (the closest-to-off tier) rather than silently
193            // upgrading the user's explicit choice to `"medium"`.
194            ReasoningLevel::None | ReasoningLevel::Minimal | ReasoningLevel::Low => "low",
195            ReasoningLevel::Medium => "medium",
196            ReasoningLevel::High | ReasoningLevel::Max | ReasoningLevel::XHigh => "high",
197        };
198        return Some(serde_json::Value::String(effort.to_string()));
199    }
200    if !supports_thinking {
201        // Model advertised no `thinking` capability — omit the field entirely.
202        return None;
203    }
204    Some(serde_json::Value::Bool(level != ReasoningLevel::None))
205}
206
207impl OllamaAdapter {
208    /// Create a new Ollama adapter for a specific model
209    pub async fn new(model_name: &str, config: Arc<BackendConfig>) -> Result<Self> {
210        let base_url = normalize_url(&config.ollama_url);
211
212        // Build HTTP client with connection pooling
213        // No global timeout -- streaming responses from cloud models can take
214        // minutes for large contexts. Per-request timeouts are set where needed.
215        let client = Client::builder()
216            .pool_max_idle_per_host(config.max_idle_per_host)
217            .pool_idle_timeout(Duration::from_secs(90))
218            .tcp_keepalive(Duration::from_secs(60))
219            .connect_timeout(Duration::from_secs(config.timeout_secs))
220            .build()
221            .map_err(|e| {
222                ModelError::Backend(BackendError::ConnectionFailed {
223                    backend: "ollama".to_string(),
224                    url: base_url.clone(),
225                    reason: e.to_string(),
226                })
227            })?;
228
229        // gpt-oss exposes a discrete `low|medium|high` enum rather than
230        // Ollama's usual binary `think: bool`. Advertising `Levels` here
231        // routes `ReasoningLevel::XHigh` / `Max` through `nearest_effort`
232        // → `High`, which `think_for_ollama` then renders as `"high"`.
233        let capabilities = if uses_effort_string_think(model_name) {
234            ModelCapabilities {
235                supports_tools: true,
236                supports_vision: false,
237                supports_reasoning: crate::models::ReasoningCapability::Levels(vec![
238                    ReasoningLevel::None,
239                    ReasoningLevel::Low,
240                    ReasoningLevel::Medium,
241                    ReasoningLevel::High,
242                ]),
243                max_context_tokens: None,
244                max_output_tokens: None,
245            }
246        } else {
247            ModelCapabilities::ollama_default()
248        };
249
250        Ok(Self {
251            client,
252            base_url,
253            model_name: model_name.to_string(),
254            capabilities,
255            thinking_cap: tokio::sync::OnceCell::new(),
256            vision_cap: tokio::sync::OnceCell::new(),
257            recovery: None,
258            status_notify: None,
259        })
260    }
261
262    /// Attach the hook that revives a dead local server. Callers pass one only
263    /// when `BackendConfig::ollama_autostart` is set; leaving it off is what
264    /// makes a path strictly read-only.
265    pub fn with_recovery(mut self, recovery: Arc<dyn LocalServerRecovery>) -> Self {
266        self.recovery = Some(recovery);
267        self
268    }
269
270    /// Attach a surface for the autostart notice on callback-less paths
271    /// (`list_models`). For console-owned contexts only — the startup
272    /// preflight and the CLI list verbs print it to stderr; never attach
273    /// anything that writes to a terminal a TUI might own.
274    pub fn with_status_notify(mut self, notify: StreamCallback) -> Self {
275        self.status_notify = Some(notify);
276        self
277    }
278
279    /// Whether this model supports `think`. Probes `/api/show` `capabilities`
280    /// once and caches the answer. Recent Ollama returns a non-empty
281    /// `capabilities` array; if it lacks `thinking` we must NOT send `think`
282    /// (it 400s). A probe failure or an empty/absent array (older Ollama, which
283    /// tolerates a stray `think`) is treated as "unknown" → keep the prior
284    /// send-`think` behavior and don't cache, so a transient blip retries.
285    async fn thinking_supported(&self) -> bool {
286        *self
287            .thinking_cap
288            .get_or_try_init(|| async {
289                match self.probe_capabilities().await {
290                    Some(caps) if !caps.is_empty() => Ok(caps.iter().any(|c| c == "thinking")),
291                    _ => Err(()),
292                }
293            })
294            .await
295            .unwrap_or(&true)
296    }
297
298    /// Whether this model advertises the `vision` capability via `/api/show`,
299    /// probed lazily and cached. Mirrors `thinking_supported`: a failed/empty
300    /// probe is treated as "unknown" and NOT cached (so a transient blip
301    /// retries), defaulting to `true` so we never falsely warn "no vision" on an
302    /// older Ollama that omits the capabilities array. Consumed by the provider
303    /// to drive the no-vision-model warning; never gates the send.
304    pub async fn vision_supported(&self) -> bool {
305        *self
306            .vision_cap
307            .get_or_try_init(|| async {
308                match self.probe_capabilities().await {
309                    Some(caps) if !caps.is_empty() => Ok(caps.iter().any(|c| c == "vision")),
310                    _ => Err(()),
311                }
312            })
313            .await
314            .unwrap_or(&true)
315    }
316
317    /// Best-effort `/api/show` probe for the model's advertised `capabilities`
318    /// array (e.g. `["completion", "tools", "thinking"]`). `None` on any error.
319    async fn probe_capabilities(&self) -> Option<Vec<String>> {
320        let url = format!("{}/api/show", self.base_url);
321        let resp = self
322            .client
323            .post(&url)
324            .json(&json!({ "model": self.model_name }))
325            .timeout(std::time::Duration::from_secs(
326                crate::constants::OLLAMA_PROBE_TIMEOUT_SECS,
327            ))
328            .send()
329            .await
330            .ok()?;
331        if !resp.status().is_success() {
332            return None;
333        }
334        let show: OllamaShowResponse = resp.json().await.ok()?;
335        Some(show.capabilities)
336    }
337
338    /// Probe `/api/show` for the model's real context window + architecture
339    /// dimensions, and `/api/tags` for its weight size. These drive auto-sizing
340    /// of `num_ctx`/`num_predict`. Best-effort: any error (server down, parse
341    /// failure, timeout) returns `None` and the caller falls back to Ollama's
342    /// defaults / the conservative cap. A short per-request timeout keeps a
343    /// slow/hung server from stalling the turn.
344    pub async fn show_model_info(&self) -> Option<OllamaModelInfo> {
345        let url = format!("{}/api/show", self.base_url);
346        let resp = self
347            .client
348            .post(&url)
349            .json(&json!({ "model": self.model_name }))
350            .timeout(std::time::Duration::from_secs(
351                crate::constants::OLLAMA_PROBE_TIMEOUT_SECS,
352            ))
353            .send()
354            .await
355            .ok()?;
356        if !resp.status().is_success() {
357            return None;
358        }
359        let show: OllamaShowResponse = resp.json().await.ok()?;
360        let context_length = context_length_from_model_info(&show.model_info);
361        let dims = dims_from_model_info(&show.model_info);
362        let weight_bytes = self.model_size_bytes().await;
363
364        // Nothing useful → signal absence so the caller retries cheaply next turn
365        // rather than caching a useless result.
366        if context_length.is_none() && dims.is_none() && weight_bytes.is_none() {
367            return None;
368        }
369        Some(OllamaModelInfo {
370            context_length,
371            dims,
372            weight_bytes,
373        })
374    }
375
376    /// This model's on-disk byte size from `/api/tags`, or `None`. Used as the
377    /// VRAM weight footprint subtracted from the auto-sizing budget.
378    async fn model_size_bytes(&self) -> Option<u64> {
379        let url = format!("{}/api/tags", self.base_url);
380        let resp = self
381            .client
382            .get(&url)
383            .timeout(std::time::Duration::from_secs(
384                crate::constants::OLLAMA_PROBE_TIMEOUT_SECS,
385            ))
386            .send()
387            .await
388            .ok()?;
389        if !resp.status().is_success() {
390            return None;
391        }
392        let tags: OllamaTagsResponse = resp.json().await.ok()?;
393        tags.models
394            .into_iter()
395            .find(|m| m.name == self.model_name)
396            .and_then(|m| m.size)
397    }
398
399    /// This model's current memory placement from `/api/ps` as
400    /// `(size_vram, total)` bytes, or `None` if it isn't loaded / Ollama is
401    /// unreachable / either figure is missing. `size_vram < total` means the
402    /// model is split between GPU and CPU/RAM (partial offload → slow). Probed
403    /// after a turn, once the model is resident.
404    pub async fn model_placement(&self) -> Option<(u64, u64)> {
405        let url = format!("{}/api/ps", self.base_url);
406        let resp = self
407            .client
408            .get(&url)
409            .timeout(std::time::Duration::from_secs(
410                crate::constants::OLLAMA_PROBE_TIMEOUT_SECS,
411            ))
412            .send()
413            .await
414            .ok()?;
415        if !resp.status().is_success() {
416            return None;
417        }
418        let ps: OllamaPsResponse = resp.json().await.ok()?;
419        ps.models
420            .into_iter()
421            .find(|m| m.name == self.model_name)
422            .and_then(|m| Some((m.size_vram?, m.size?)))
423    }
424
425    /// Handle a streaming response, emitting typed `StreamEvent`s through
426    /// the optional callback. The legacy text-callback shape is provided
427    /// by the `chat()` shim below, which translates these typed events
428    /// back into the marker-encoded text stream the older callers expect.
429    async fn handle_stream(
430        &self,
431        response: reqwest::Response,
432        callback: Option<StreamCallback>,
433        hide_reasoning_trace: bool,
434    ) -> Result<ModelResponse> {
435        if !response.status().is_success() {
436            let status = response.status().as_u16();
437            let debug =
438                crate::models::error::ResponseDebugContext::from_headers(response.headers());
439            let error_text = response
440                .text()
441                .await
442                .unwrap_or_else(|_| "Unknown error".to_string());
443            return Err(ModelError::Backend(BackendError::HttpError {
444                status,
445                message: error_text,
446                debug,
447            }));
448        }
449
450        let mut stream = response.bytes_stream();
451        let mut acc = StreamAccumulator {
452            content: String::new(),
453            thinking: String::new(),
454            tool_calls: Vec::new(),
455            hide_reasoning_trace,
456            prompt_tokens: 0,
457            completion_tokens: 0,
458            saw_usage: false,
459            done_reason: None,
460            saw_done: false,
461            truncated: false,
462        };
463
464        // Buffer for incomplete JSON lines split across TCP chunks. Ollama
465        // sends newline-delimited JSON, but bytes_stream() chunks don't
466        // align with line boundaries — a JSON object can split across two
467        // or more TCP packets. We also buffer raw bytes (Vec<u8>) rather
468        // than a String because TCP chunks don't align with UTF-8
469        // codepoint boundaries either; see `drain_complete_lines` for the
470        // full rationale.
471        let mut line_buffer: Vec<u8> = Vec::new();
472
473        while let Some(chunk_result) = stream.next().await {
474            let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
475            // A stream that never sends a newline would otherwise grow
476            // `line_buffer` without bound. At this point it holds only the
477            // un-terminated residue from the previous drain, so this never trips
478            // on legitimately buffered complete lines — mirrors the SSE cap (R5).
479            if line_buffer.len() > crate::constants::MAX_SSE_BUFFER_BYTES {
480                return Err(ModelError::StreamError(format!(
481                    "NDJSON stream exceeded {} byte reassembly cap without a complete line",
482                    crate::constants::MAX_SSE_BUFFER_BYTES
483                )));
484            }
485            line_buffer.extend_from_slice(&chunk);
486
487            for line in drain_complete_lines(&mut line_buffer) {
488                if line.trim().is_empty() {
489                    continue;
490                }
491
492                let json_chunk = parse_ollama_stream_frame(&line)?;
493
494                Self::process_stream_chunk(&json_chunk, callback.as_ref(), &mut acc);
495            }
496        }
497
498        // Process any remaining buffered content after the stream ends
499        // (the final JSON line may not have a trailing newline).
500        if !line_buffer.is_empty() {
501            let trailing = String::from_utf8_lossy(&line_buffer).into_owned();
502            if !trailing.trim().is_empty() {
503                let json_chunk = parse_ollama_stream_frame(trailing.trim())?;
504
505                Self::process_stream_chunk(&json_chunk, callback.as_ref(), &mut acc);
506            }
507        }
508
509        // F56: a stream that ended before Ollama's terminal `done` chunk was
510        // dropped mid-response. Surface it as a stream error instead of a clean
511        // `Ok` that's indistinguishable from a real completion. Keyed off the
512        // `done` frame (not `done_reason`), so a context-full truncation — a
513        // real `done` with `done_reason: "length"`, recovered via
514        // compact-and-continue — is preserved, not misclassified.
515        if acc.closed_abnormally() {
516            return Err(ModelError::StreamError(
517                "Ollama stream closed before the terminal `done` chunk; the \
518                 connection was likely dropped mid-response"
519                    .to_string(),
520            ));
521        }
522
523        // `None` when the stream never reported eval counts, so the reducer keeps
524        // its estimate instead of resetting the context gauge to zero (F54).
525        // Computed before the field moves below so `usage()` can borrow `acc`.
526        let usage = acc.usage();
527        let stop_reason = acc.done_reason.as_deref().map(map_ollama_done_reason);
528        let thinking = if acc.thinking.is_empty() {
529            None
530        } else {
531            Some(acc.thinking)
532        };
533        let tool_calls = if acc.tool_calls.is_empty() {
534            None
535        } else {
536            Some(acc.tool_calls)
537        };
538
539        // F3: the adapter no longer emits a terminal `Done` through the
540        // callback. The v0.7 provider wrapper (`providers::model::*`)
541        // emits the authoritative `StreamEvent::Done { usage,
542        // provider_continuation }` from the returned `ModelResponse`.
543        // Emitting here would race the wrapper's Done (ordering aside)
544        // and drop the provider_continuation for Anthropic.
545
546        Ok(ModelResponse {
547            content: acc.content,
548            usage,
549            model_name: self.model_name.clone(),
550            stop_reason,
551            thinking,
552            tool_calls,
553            provider_continuation: None,
554        })
555    }
556
557    /// Process a single parsed stream chunk, updating accumulators and
558    /// emitting typed events.
559    ///
560    /// Event ordering within a chunk: reasoning (if any) → tool calls (if
561    /// any) → text (if any). The `Done` event is emitted by the caller
562    /// (`handle_stream`) once the stream closes, never here.
563    ///
564    /// Once the per-stream `MAX_RESPONSE_CHARS` cap trips (`acc.truncated`),
565    /// further content / thinking chunks are silently dropped — both from
566    /// the accumulator AND from typed-event emission. Tool calls and token
567    /// usage are still recorded because those are bounded.
568    fn process_stream_chunk(
569        json_chunk: &OllamaStreamChunk,
570        callback: Option<&StreamCallback>,
571        acc: &mut StreamAccumulator,
572    ) {
573        // Reasoning / thinking content. Always recorded into `acc.thinking`
574        // (so `ModelResponse.thinking` stays populated for backward-compat
575        // callers); only emitted via typed callback when not hidden.
576        if let Some(ref thinking_chunk) = json_chunk.message.thinking
577            && !acc.truncated
578            && !thinking_chunk.is_empty()
579        {
580            if let Some(cb) = callback
581                && !acc.hide_reasoning_trace
582            {
583                cb(StreamEvent::Reasoning(ReasoningChunk {
584                    text: thinking_chunk.clone(),
585                    signature: None,
586                }));
587            }
588            push_capped(
589                &mut acc.thinking,
590                thinking_chunk,
591                &mut acc.truncated,
592                MAX_RESPONSE_CHARS,
593            );
594        }
595
596        // Tool calls — bounded, no cap needed. Emitted as typed events
597        // immediately so streaming consumers can react before completion.
598        if let Some(ref tool_calls) = json_chunk.message.tool_calls {
599            acc.tool_calls.extend(tool_calls.clone());
600            if let Some(cb) = callback {
601                for tc in tool_calls {
602                    cb(StreamEvent::ToolCall(tc.clone()));
603                }
604            }
605        }
606
607        // Regular text content.
608        if !json_chunk.message.content.is_empty() && !acc.truncated {
609            if let Some(cb) = callback {
610                cb(StreamEvent::Text(json_chunk.message.content.clone()));
611            }
612            push_capped(
613                &mut acc.content,
614                &json_chunk.message.content,
615                &mut acc.truncated,
616                MAX_RESPONSE_CHARS,
617            );
618        }
619
620        // Capture token usage + stop reason from the `done` chunk. `saw_usage`
621        // is set only when a real eval count arrives, so a stream cut before
622        // `done` reports `None` usage instead of zero (F54).
623        if json_chunk.done {
624            // F56: the terminal frame arrived — the stream completed normally
625            // (even when `done_reason`/eval counts are absent).
626            acc.saw_done = true;
627            if let Some(count) = json_chunk.prompt_eval_count {
628                acc.prompt_tokens = count;
629                acc.saw_usage = true;
630            }
631            if let Some(count) = json_chunk.eval_count {
632                acc.completion_tokens = count;
633                acc.saw_usage = true;
634            }
635            if json_chunk.done_reason.is_some() {
636                acc.done_reason = json_chunk.done_reason.clone();
637            }
638        }
639    }
640
641    /// Build the JSON request body shared between `chat` (legacy text
642    /// callback) and `chat_typed` (new typed events). Centralizing here
643    /// avoids two copies of the message-formatting + tool-filtering +
644    /// option-assembly logic.
645    fn build_request_body(
646        &self,
647        messages: &[ChatMessage],
648        config: &ModelConfig,
649        stream: bool,
650        supports_thinking: bool,
651    ) -> serde_json::Value {
652        let ollama_opts = config.ollama_options();
653
654        let mut json_messages = Vec::new();
655
656        // Ollama doesn't cache; static prompt + MERMAID.md suffix are joined
657        // with a `---` separator via combined_system_prompt().
658        if let Some(combined) = config.combined_system_prompt() {
659            json_messages.push(json!({
660                "role": "system",
661                "content": combined
662            }));
663        }
664
665        for msg in messages {
666            let role = match msg.role {
667                MessageRole::User => "user",
668                MessageRole::Assistant => "assistant",
669                MessageRole::System => "system",
670                MessageRole::Tool => "tool",
671            };
672            let mut json_msg = json!({
673                "role": role,
674                "content": msg.content
675            });
676            if msg.role == MessageRole::Assistant
677                && let Some(ref tool_calls) = msg.tool_calls
678            {
679                json_msg["tool_calls"] = json!(tool_calls);
680            }
681            if msg.role == MessageRole::Tool
682                && let Some(ref tool_name) = msg.tool_name
683            {
684                json_msg["tool_name"] = json!(tool_name);
685            }
686            if let Some(ref images) = msg.images
687                && !images.is_empty()
688            {
689                json_msg["images"] = json!(images);
690            }
691            json_messages.push(json_msg);
692        }
693
694        // Tools come from `config.tools` (populated by the provider wrapper
695        // from `ChatRequest.tools`). The registry only registers a web tool
696        // when its backend is usable — native `web_fetch` needs no key, and the
697        // Ollama-backed web tools are gated on `OLLAMA_API_KEY` at registration
698        // — so whatever reaches here is advertisable as-is.
699        let tools: Vec<&serde_json::Value> = config.tools.iter().collect();
700
701        let mut request_body = json!({
702            "model": self.model_name,
703            "messages": json_messages,
704            "stream": stream,
705            "tools": &tools,
706        });
707
708        // `--output-schema` formatting turn: Ollama's structured output.
709        if let Some(schema) = &config.output_schema {
710            request_body["format"] = schema.clone();
711        }
712
713        // `think` parameter: most Ollama models accept `think: bool`, gpt-oss
714        // requires a string enum, and a model that doesn't advertise `thinking`
715        // must not receive the field at all (it 400s). `think_for_ollama`
716        // returns `None` in that last case so the key is omitted (#122).
717        if let Some(think) = think_for_ollama(&self.model_name, config.reasoning, supports_thinking)
718        {
719            request_body["think"] = think;
720        }
721        tracing::debug!(
722            "think reasoning={:?} supports_thinking={} shape={}",
723            config.reasoning,
724            supports_thinking,
725            if uses_effort_string_think(&self.model_name) {
726                "string"
727            } else {
728                "bool"
729            }
730        );
731
732        tracing::debug!("Sending {} tools to Ollama", tools.len());
733        tracing::debug!(
734            "Request body tools: {}",
735            serde_json::to_string_pretty(&tools).unwrap_or_default()
736        );
737
738        let mut options = json!({});
739        // Clamp to the conventional 0..=2 range (matches the other adapters).
740        options["temperature"] = json!(config.temperature.clamp(0.0, 2.0));
741        if let Some(num_ctx) = ollama_opts.num_ctx {
742            options["num_ctx"] = json!(num_ctx);
743        }
744        // Output cap. Without this Ollama generates unbounded and only stops when
745        // the (often tiny default) num_ctx fills — the truncation bug. Derived
746        // from max_tokens + reasoning headroom in `build_model_config`.
747        if let Some(num_predict) = ollama_opts.num_predict {
748            options["num_predict"] = json!(num_predict);
749        }
750        if let Some(num_gpu) = ollama_opts.num_gpu {
751            options["num_gpu"] = json!(num_gpu);
752        }
753        if let Some(num_thread) = ollama_opts.num_thread {
754            options["num_thread"] = json!(num_thread);
755        }
756        if let Some(numa) = ollama_opts.numa {
757            options["numa"] = json!(numa);
758        }
759        tracing::debug!(
760            "Ollama sizing: num_ctx={:?} num_predict={:?}",
761            ollama_opts.num_ctx,
762            ollama_opts.num_predict
763        );
764        request_body["options"] = options;
765
766        request_body
767    }
768
769    /// POST /api/chat with the given body and return the raw response.
770    /// Transparently retries on 5xx, 429, or reqwest connect failures
771    /// via `crate::models::retry::retry_transient_http`. Mid-stream failures
772    /// (body consumption) are NOT retried — partial content has already
773    /// reached the caller at that point.
774    async fn send_chat(
775        &self,
776        body: &serde_json::Value,
777        notify: Option<&StreamCallback>,
778    ) -> Result<reqwest::Response> {
779        let url = format!("{}/api/chat", self.base_url);
780        self.with_local_recovery(notify, || async {
781            self.client.post(&url).json(body).send().await.map_err(|e| {
782                ModelError::Backend(BackendError::ConnectionFailed {
783                    backend: "ollama".to_string(),
784                    url: self.base_url.clone(),
785                    reason: e.to_string(),
786                })
787            })
788        })
789        .await
790    }
791
792    /// Run `op` under the transient-HTTP retry policy; if it still ends in
793    /// `ConnectionFailed` and the server is local, start it
794    /// (`ollama::ensure_running`) and run one more retry round. The
795    /// "it just works" contract: a dead local server is mermaid's problem,
796    /// not the user's. When auto-start itself fails, its hint is appended to
797    /// the connection error so the surfaced message says what to do next;
798    /// non-local URLs pass their error through untouched.
799    ///
800    /// `notify` carries the moment-of-spawn notice ("Starting the local
801    /// Ollama server…") out as a `StreamEvent::Status` — the revival can
802    /// block ~15s behind an otherwise generic spinner, and the spawned
803    /// server outlives mermaid, so this one line covers latency feedback,
804    /// discoverability, and consent at once. `ensure_running` invokes it
805    /// only when a spawn is actually committed (never on NotLocal /
806    /// Disabled / already-healthy / binary-missing), so no false notices
807    /// reach the user.
808    ///
809    /// The first round deliberately does NOT retry a refused connection.
810    /// Backing off and asking a closed port again cannot succeed — only
811    /// `ensure_running` can — and on Windows a refused loopback connect costs
812    /// ~2s (the SYN is retransmitted before `WSAECONNREFUSED`), so retrying it
813    /// three times bought ~9s of dead wait ahead of both the recovery and the
814    /// enumeration verbs, which pass `autostart: false` and want the dead state
815    /// reported promptly. 5xx and 429 from a server that IS running still
816    /// retry, here and in the post-recovery round.
817    async fn with_local_recovery<F, Fut>(
818        &self,
819        notify: Option<&StreamCallback>,
820        mut op: F,
821    ) -> Result<reqwest::Response>
822    where
823        F: FnMut() -> Fut,
824        Fut: std::future::Future<Output = Result<reqwest::Response>>,
825    {
826        let first = crate::models::retry::retry_transient_http_no_connect_retry(&mut op).await;
827        let Some(recovery) = self.recovery.as_ref() else {
828            return first;
829        };
830        if !matches!(
831            first,
832            Err(ModelError::Backend(BackendError::ConnectionFailed { .. }))
833        ) {
834            return first;
835        }
836        // Stream callback first (chat), constructor sink second (console
837        // list paths) — whichever exists carries the one notice.
838        let ensured = match notify.or(self.status_notify.as_ref()) {
839            Some(cb) => {
840                let forward = |text: &str| cb(StreamEvent::Status(text.to_string()));
841                recovery
842                    .ensure_running(&self.base_url, Some(&forward))
843                    .await
844            },
845            None => recovery.ensure_running(&self.base_url, None).await,
846        };
847        match ensured {
848            Ok(()) => crate::models::retry::retry_transient_http(&mut op).await,
849            Err(Some(hint)) => first.map_err(|e| append_reason_hint(e, &hint)),
850            Err(None) => first,
851        }
852    }
853
854    /// Decode the single non-streaming response body into a `ModelResponse`.
855    /// Used by both `chat` (no callback) and `chat_typed` (no callback).
856    async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
857        if !response.status().is_success() {
858            let status = response.status().as_u16();
859            let debug =
860                crate::models::error::ResponseDebugContext::from_headers(response.headers());
861            let error_text = response
862                .text()
863                .await
864                .unwrap_or_else(|_| "Unknown error".to_string());
865            return Err(ModelError::Backend(BackendError::HttpError {
866                status,
867                message: error_text,
868                debug,
869            }));
870        }
871
872        let json: OllamaStreamChunk =
873            response.json().await.map_err(|e| ModelError::ParseError {
874                message: format!("Failed to parse response: {}", e),
875                raw: None,
876            })?;
877
878        let thinking = json.message.thinking.filter(|t| !t.is_empty());
879        let tool_calls = json.message.tool_calls.filter(|tc| !tc.is_empty());
880
881        let prompt_tokens = json.prompt_eval_count.unwrap_or(0);
882        let completion_tokens = json.eval_count.unwrap_or(0);
883
884        Ok(ModelResponse {
885            content: json.message.content,
886            usage: Some(TokenUsage::provider(prompt_tokens, completion_tokens)),
887            model_name: self.model_name.clone(),
888            stop_reason: json.done_reason.as_deref().map(map_ollama_done_reason),
889            thinking,
890            tool_calls,
891            provider_continuation: None,
892        })
893    }
894}
895
896#[async_trait]
897impl Model for OllamaAdapter {
898    fn name(&self) -> &str {
899        &self.model_name
900    }
901
902    fn capabilities(&self) -> &ModelCapabilities {
903        &self.capabilities
904    }
905
906    async fn list_models(&self) -> Result<Vec<String>> {
907        let url = format!("{}/api/tags", self.base_url);
908
909        // Recovery here is what makes a cold boot self-heal: the startup
910        // model check (`ollama::installer`) and the model picker both land on
911        // this call, so a dead local server is revived before the first chat.
912        // No stream callback exists on this path (notify: None) — its callers
913        // are console contexts where the pause reads as startup work.
914        let response = self
915            .with_local_recovery(None, || async {
916                self.client.get(&url).send().await.map_err(|e| {
917                    ModelError::Backend(BackendError::ConnectionFailed {
918                        backend: "ollama".to_string(),
919                        url: self.base_url.clone(),
920                        reason: e.to_string(),
921                    })
922                })
923            })
924            .await?;
925
926        if !response.status().is_success() {
927            return Err(ModelError::Backend(BackendError::HttpError {
928                status: response.status().as_u16(),
929                message: "Failed to list models".to_string(),
930                debug: crate::models::error::ResponseDebugContext::from_headers(response.headers()),
931            }));
932        }
933
934        let tags: OllamaTagsResponse =
935            response.json().await.map_err(|e| ModelError::ParseError {
936                message: format!("Failed to parse tags response: {}", e),
937                raw: None,
938            })?;
939
940        Ok(tags.models.into_iter().map(|m| m.name).collect())
941    }
942
943    async fn chat(
944        &self,
945        messages: &[ChatMessage],
946        config: &ModelConfig,
947        callback: Option<StreamCallback>,
948    ) -> Result<ModelResponse> {
949        let stream = callback.is_some();
950        let supports_thinking = self.thinking_supported().await;
951        let request_body = self.build_request_body(messages, config, stream, supports_thinking);
952        // `callback` doubles as the autostart notice channel: if the local
953        // server has to be started, the user sees a status line instead of
954        // ~15s of bare spinner.
955        let response = self.send_chat(&request_body, callback.as_ref()).await?;
956
957        if stream {
958            self.handle_stream(response, callback, config.hide_reasoning_trace)
959                .await
960        } else {
961            self.decode_non_streaming(response).await
962        }
963    }
964}
965
966// Response types
967
968#[derive(Debug, Serialize, Deserialize)]
969struct OllamaStreamChunk {
970    message: OllamaMessage,
971    done: bool,
972    #[serde(default)]
973    prompt_eval_count: Option<usize>,
974    #[serde(default)]
975    eval_count: Option<usize>,
976    #[serde(default)]
977    done_reason: Option<String>,
978}
979
980#[derive(Debug, Serialize, Deserialize)]
981struct OllamaMessage {
982    role: String,
983    // F55: a frame may omit `content` (vs sending `""`) — e.g. a thinking-only
984    // or tool-call-only delta. Without `default` the whole-chunk parse fails
985    // ("missing field content") and tears down the entire stream, matching the
986    // `thinking`/`tool_calls` siblings which already default.
987    #[serde(default)]
988    content: String,
989    #[serde(default)]
990    thinking: Option<String>,
991    #[serde(default)]
992    tool_calls: Option<Vec<crate::models::ToolCall>>,
993}
994
995#[derive(Debug, Serialize, Deserialize)]
996pub(crate) struct OllamaTagsResponse {
997    pub(crate) models: Vec<OllamaModel>,
998}
999
1000#[derive(Debug, Serialize, Deserialize)]
1001pub(crate) struct OllamaModel {
1002    pub(crate) name: String,
1003    /// On-disk (quantized) byte size — closely approximates the VRAM weight
1004    /// footprint, which auto-sizing subtracts from the memory budget.
1005    #[serde(default)]
1006    pub(crate) size: Option<u64>,
1007}
1008
1009/// Subset of the `/api/show` response we parse — only `model_info` (the
1010/// architecture-prefixed dimensions). Byte size is NOT here; it comes from
1011/// `/api/tags`.
1012#[derive(Debug, Deserialize)]
1013struct OllamaShowResponse {
1014    #[serde(default)]
1015    model_info: serde_json::Value,
1016    /// Advertised capabilities (`completion`, `tools`, `thinking`, `vision`, …).
1017    /// Absent on older Ollama; used to gate the `think` field (#122).
1018    #[serde(default)]
1019    capabilities: Vec<String>,
1020}
1021
1022/// `/api/ps` response — currently-loaded models and their memory placement.
1023/// Extra fields (`digest`, `expires_at`, `details`, …) are ignored.
1024#[derive(Debug, Serialize, Deserialize)]
1025pub(crate) struct OllamaPsResponse {
1026    #[serde(default)]
1027    pub(crate) models: Vec<OllamaPsModel>,
1028}
1029
1030#[derive(Debug, Serialize, Deserialize)]
1031pub(crate) struct OllamaPsModel {
1032    pub(crate) name: String,
1033    /// Total bytes the loaded model occupies (weights + KV + buffers).
1034    #[serde(default)]
1035    pub(crate) size: Option<u64>,
1036    /// Of that, the bytes resident in VRAM. Less than `size` ⇒ partial offload.
1037    #[serde(default)]
1038    pub(crate) size_vram: Option<u64>,
1039}
1040
1041/// Capabilities probed from `/api/show` (+ `/api/tags` for the weight size),
1042/// used to auto-size `num_ctx`. All fields are best-effort and independently
1043/// optional. Serialized into the `provider_probes` cache so subsequent sessions
1044/// skip the probe.
1045#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1046pub struct OllamaModelInfo {
1047    /// The model's architectural max context window.
1048    pub context_length: Option<usize>,
1049    /// Architecture dimensions for the KV-cache estimate.
1050    pub dims: Option<ModelDims>,
1051    /// On-disk (quantized) weight bytes.
1052    pub weight_bytes: Option<u64>,
1053}
1054
1055// Helper functions
1056
1057/// Append an auto-start hint to a `ConnectionFailed` reason so the surfaced
1058/// error explains what mermaid tried and what the user can do ("Ollama isn't
1059/// installed — …"). Other error shapes pass through untouched.
1060fn append_reason_hint(error: ModelError, hint: &str) -> ModelError {
1061    match error {
1062        ModelError::Backend(BackendError::ConnectionFailed {
1063            backend,
1064            url,
1065            reason,
1066        }) => ModelError::Backend(BackendError::ConnectionFailed {
1067            backend,
1068            url,
1069            reason: format!("{reason}. {hint}"),
1070        }),
1071        other => other,
1072    }
1073}
1074
1075/// Parse one newline-delimited Ollama stream frame into an `OllamaStreamChunk`.
1076///
1077/// F53: a mid-stream `{"error":"..."}` frame lacks the `message`/`done` fields
1078/// of `OllamaStreamChunk`, so a direct typed parse fails with a generic
1079/// `ParseError("missing field `message`")` and the real provider error survives
1080/// only inside `raw`. Check for a top-level `error` string first and surface it
1081/// as a typed `ProviderError` (mirrors openai_compat.rs / gemini.rs stream
1082/// paths) before falling back to the typed-chunk parse.
1083fn parse_ollama_stream_frame(line: &str) -> Result<OllamaStreamChunk> {
1084    if let Ok(value) = serde_json::from_str::<serde_json::Value>(line)
1085        && let Some(message) = value.get("error").and_then(|v| v.as_str())
1086    {
1087        return Err(ModelError::Backend(BackendError::ProviderError {
1088            provider: "ollama".to_string(),
1089            code: None,
1090            message: message.to_string(),
1091            debug: crate::models::error::ResponseDebugContext::default(),
1092        }));
1093    }
1094    serde_json::from_str(line).map_err(|e| ModelError::ParseError {
1095        message: format!("Failed to parse Ollama response: {}", e),
1096        raw: Some(line.to_string()),
1097    })
1098}
1099
1100/// Map Ollama's `done_reason` to the shared `FinishReason`. Ollama emits `"stop"`
1101/// (natural end) and `"length"` (hit `num_predict`/context); anything else
1102/// (operational reasons like `"load"`) is preserved via `Other` so it still
1103/// surfaces rather than being dropped to `None` (#13).
1104fn map_ollama_done_reason(s: &str) -> FinishReason {
1105    match s {
1106        "stop" => FinishReason::Stop,
1107        "length" => FinishReason::Length,
1108        other => FinishReason::Other(other.to_string()),
1109    }
1110}
1111
1112/// Coerce a `model_info` JSON value to `usize` (the dimension keys are integers).
1113fn json_to_usize(v: &serde_json::Value) -> Option<usize> {
1114    v.as_u64().map(|n| n as usize)
1115}
1116
1117/// The model's max context window from `/api/show` `model_info`. Keys are
1118/// architecture-prefixed (`qwen2.context_length`, `llama.context_length`,
1119/// `gptoss.context_length`, …); we prefer the prefix named by
1120/// `general.architecture`, then fall back to any key ending in `.context_length`.
1121/// Generic so a new architecture needs no code change.
1122fn context_length_from_model_info(model_info: &serde_json::Value) -> Option<usize> {
1123    let obj = model_info.as_object()?;
1124    if let Some(arch) = obj.get("general.architecture").and_then(|v| v.as_str())
1125        && let Some(v) = obj
1126            .get(&format!("{arch}.context_length"))
1127            .and_then(json_to_usize)
1128    {
1129        return Some(v);
1130    }
1131    obj.iter()
1132        .find(|(k, _)| k.ends_with(".context_length"))
1133        .and_then(|(_, v)| json_to_usize(v))
1134}
1135
1136/// Architecture dimensions for the KV-cache estimate, by arch-suffixed key.
1137/// `head_count_kv` defaults to `head_count` when absent (non-GQA models).
1138/// Returns `None` if any required dimension is missing.
1139fn dims_from_model_info(model_info: &serde_json::Value) -> Option<ModelDims> {
1140    let obj = model_info.as_object()?;
1141    let by_suffix = |suffix: &str| -> Option<usize> {
1142        obj.iter()
1143            .find(|(k, _)| k.ends_with(suffix))
1144            .and_then(|(_, v)| json_to_usize(v))
1145    };
1146    let head_count = by_suffix(".attention.head_count")?;
1147    Some(ModelDims {
1148        block_count: by_suffix(".block_count")?,
1149        head_count,
1150        head_count_kv: by_suffix(".attention.head_count_kv").unwrap_or(head_count),
1151        embedding_length: by_suffix(".embedding_length")?,
1152    })
1153}
1154
1155fn normalize_url(url: &str) -> String {
1156    let mut normalized = url.trim().to_string();
1157
1158    // Replace 0.0.0.0 with 127.0.0.1
1159    if normalized.contains("0.0.0.0") {
1160        normalized = normalized.replace("0.0.0.0", "127.0.0.1");
1161    }
1162
1163    // Add a scheme if missing, chosen by host class: loopback / private / LAN
1164    // hosts may use cleartext http, but a public host defaults to https so
1165    // prompt data isn't sent over the open internet in the clear (#86). An
1166    // explicit scheme (http or https) is always respected. Mirrors the factory's
1167    // `validate_provider_base_url` gate.
1168    if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
1169        let host = normalized.split(['/', ':']).next().unwrap_or("");
1170        let scheme = if crate::utils::classify_host(host).is_internal() {
1171            "http"
1172        } else {
1173            "https"
1174        };
1175        normalized = format!("{}://{}", scheme, normalized);
1176    }
1177
1178    // Add default Ollama port if missing (only for http; https keeps its default 443).
1179    // Check the authority portion only (before first '/') to avoid appending the port
1180    // after a path component (e.g., "http://host/v1" must NOT become "http://host/v1:11434").
1181    if let Some(after_scheme) = normalized.strip_prefix("http://") {
1182        let (authority, path) = match after_scheme.find('/') {
1183            Some(i) => (&after_scheme[..i], &after_scheme[i..]),
1184            None => (after_scheme, ""),
1185        };
1186        if !authority.contains(':') {
1187            normalized = format!("http://{}:11434{}", authority, path);
1188        }
1189    }
1190    // For https:// without a port, don't add :11434 — the default port (443) is correct
1191
1192    normalized
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::{TRUNCATION_MARKER, normalize_url, push_capped, uses_effort_string_think};
1198
1199    // --- push_capped: response-size cap for streaming accumulators ---
1200
1201    #[test]
1202    fn push_capped_under_cap_appends_normally() {
1203        let mut buf = String::new();
1204        let mut truncated = false;
1205        push_capped(&mut buf, "hello", &mut truncated, 100);
1206        push_capped(&mut buf, " world", &mut truncated, 100);
1207        assert_eq!(buf, "hello world");
1208        assert!(!truncated);
1209    }
1210
1211    #[test]
1212    fn push_capped_truncates_once_then_drops_chunks() {
1213        let mut buf = String::new();
1214        let mut truncated = false;
1215        let cap = 32;
1216        // First chunk overflows.
1217        push_capped(&mut buf, &"a".repeat(200), &mut truncated, cap);
1218        assert!(truncated);
1219        assert!(buf.ends_with(TRUNCATION_MARKER));
1220        let len_after_first = buf.len();
1221        // Subsequent chunks are silently dropped.
1222        push_capped(&mut buf, &"b".repeat(200), &mut truncated, cap);
1223        push_capped(&mut buf, "tail", &mut truncated, cap);
1224        assert_eq!(buf.len(), len_after_first);
1225        assert_eq!(buf.matches(TRUNCATION_MARKER).count(), 1);
1226    }
1227
1228    // --- /api/ps placement parsing (mirrors model_placement's selection) ---
1229
1230    #[test]
1231    fn ps_response_selects_model_and_handles_missing_fields() {
1232        // Realistic body: a partially-offloaded model, the target fully on GPU,
1233        // one entry missing size_vram, plus extra fields we must ignore.
1234        let body = serde_json::json!({
1235            "models": [
1236                { "name": "other:7b", "size": 8_000_000_000u64, "size_vram": 4_000_000_000u64,
1237                  "digest": "abc", "expires_at": "2026-01-01T00:00:00Z" },
1238                { "name": "ornith:9b", "size": 6_000_000_000u64, "size_vram": 6_000_000_000u64 },
1239                { "name": "nogpu:1b", "size": 1_000_000_000u64 },
1240            ]
1241        });
1242        let ps: super::OllamaPsResponse = serde_json::from_value(body).unwrap();
1243        // Same selection model_placement does: find by name, require both bytes.
1244        let pick = |name: &str| {
1245            ps.models
1246                .iter()
1247                .find(|m| m.name == name)
1248                .and_then(|m| Some((m.size_vram?, m.size?)))
1249        };
1250        assert_eq!(pick("ornith:9b"), Some((6_000_000_000, 6_000_000_000)));
1251        assert_eq!(pick("other:7b"), Some((4_000_000_000, 8_000_000_000)));
1252        assert_eq!(pick("nogpu:1b"), None); // missing size_vram → None
1253        assert_eq!(pick("absent:1b"), None); // not loaded → None
1254    }
1255
1256    #[test]
1257    fn push_capped_respects_char_boundary_for_cjk() {
1258        let mut buf = String::new();
1259        let mut truncated = false;
1260        // 4 bytes lands inside the second 3-byte 你; floor must back off to 3.
1261        push_capped(&mut buf, "你你你你", &mut truncated, 4);
1262        let body = &buf[..buf.find('\n').unwrap()];
1263        assert_eq!(body, "你");
1264        assert!(buf.ends_with(TRUNCATION_MARKER));
1265    }
1266
1267    #[test]
1268    fn test_normalize_url_bare_host() {
1269        assert_eq!(normalize_url("localhost"), "http://localhost:11434");
1270    }
1271
1272    #[test]
1273    fn test_normalize_url_http_no_port() {
1274        assert_eq!(normalize_url("http://localhost"), "http://localhost:11434");
1275    }
1276
1277    #[test]
1278    fn test_normalize_url_http_with_port() {
1279        assert_eq!(
1280            normalize_url("http://localhost:11434"),
1281            "http://localhost:11434"
1282        );
1283    }
1284
1285    #[test]
1286    fn test_normalize_url_custom_port() {
1287        assert_eq!(normalize_url("http://host:8080"), "http://host:8080");
1288    }
1289
1290    #[test]
1291    fn test_normalize_url_with_path_no_port() {
1292        assert_eq!(
1293            normalize_url("http://ollama.example.com/v1"),
1294            "http://ollama.example.com:11434/v1"
1295        );
1296    }
1297
1298    #[test]
1299    fn test_normalize_url_with_path_and_port() {
1300        assert_eq!(
1301            normalize_url("http://ollama.example.com:8080/v1"),
1302            "http://ollama.example.com:8080/v1"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_normalize_url_https_no_port_added() {
1308        assert_eq!(
1309            normalize_url("https://ollama.example.com"),
1310            "https://ollama.example.com"
1311        );
1312    }
1313
1314    #[test]
1315    fn test_normalize_url_replaces_0000() {
1316        assert_eq!(
1317            normalize_url("http://0.0.0.0:11434"),
1318            "http://127.0.0.1:11434"
1319        );
1320    }
1321
1322    #[test]
1323    fn normalize_url_public_host_defaults_to_https() {
1324        // #86: a scheme-less public host must not be addressed over cleartext.
1325        assert_eq!(
1326            normalize_url("my-remote-ollama.com:11434"),
1327            "https://my-remote-ollama.com:11434"
1328        );
1329    }
1330
1331    #[test]
1332    fn normalize_url_private_host_stays_http() {
1333        // Loopback / LAN hosts keep cleartext http (no port → :11434 added).
1334        assert_eq!(normalize_url("192.168.1.50"), "http://192.168.1.50:11434");
1335        assert_eq!(normalize_url("127.0.0.1:11434"), "http://127.0.0.1:11434");
1336    }
1337
1338    // --- think mapping from ReasoningLevel (Step 4) ---
1339
1340    use super::OllamaAdapter;
1341    use crate::models::config::{BackendConfig, ModelConfig};
1342    use crate::models::reasoning::ReasoningLevel;
1343    use crate::models::types::ChatMessage;
1344    use std::sync::Arc;
1345
1346    async fn make_adapter() -> OllamaAdapter {
1347        // `OllamaAdapter::new` builds an HTTP client but does NOT contact
1348        // the server, so this works offline.
1349        OllamaAdapter::new("test-model", Arc::new(BackendConfig::default()))
1350            .await
1351            .expect("adapter")
1352    }
1353
1354    #[tokio::test]
1355    async fn connection_failure_passes_through_when_autostart_disabled() {
1356        // Reserve a port, then release it so nothing is listening. With
1357        // autostart disabled the adapter must surface the plain connection
1358        // error — no `ensure_running` attempt, no hint injected. (The
1359        // autostart=true path is deliberately not exercised here: on a dev
1360        // box it would spawn a real `ollama serve`.)
1361        let port = {
1362            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1363            listener.local_addr().expect("addr").port()
1364        };
1365        let backend = BackendConfig {
1366            ollama_url: format!("http://127.0.0.1:{port}"),
1367            timeout_secs: 1,
1368            max_idle_per_host: 1,
1369            ollama_autostart: false,
1370        };
1371        use crate::models::traits::Model;
1372        let adapter = OllamaAdapter::new("test-model", Arc::new(backend))
1373            .await
1374            .expect("adapter");
1375        let err = adapter
1376            .list_models()
1377            .await
1378            .expect_err("dead port must fail");
1379        let msg = err.to_string();
1380        assert!(msg.contains("Failed to connect to ollama"), "got: {msg}");
1381        assert!(
1382            !msg.contains("auto-start") && !msg.contains("ollama.com/download"),
1383            "no hint expected with autostart disabled, got: {msg}"
1384        );
1385    }
1386
1387    #[test]
1388    fn append_reason_hint_enriches_connection_failed_only() {
1389        use crate::models::error::{BackendError, ModelError};
1390        let base = ModelError::Backend(BackendError::ConnectionFailed {
1391            backend: "ollama".into(),
1392            url: "http://localhost:11434".into(),
1393            reason: "connection refused".into(),
1394        });
1395        let enriched = super::append_reason_hint(base, "install it from https://ollama.com");
1396        assert!(
1397            enriched
1398                .to_string()
1399                .contains("connection refused. install it from https://ollama.com"),
1400            "got: {enriched}"
1401        );
1402        // Non-connection errors pass through untouched.
1403        let other = ModelError::ParseError {
1404            message: "bad json".into(),
1405            raw: None,
1406        };
1407        let untouched = super::append_reason_hint(other, "should not appear");
1408        assert!(!untouched.to_string().contains("should not appear"));
1409    }
1410
1411    /// Adapter contract (see `MessageAudience`): harness steering must reach
1412    /// the model. Ollama carries a native system role in history, so the
1413    /// reminder passes through at the TAIL — the position weak local models
1414    /// were observed to actually read.
1415    #[tokio::test]
1416    async fn model_directed_system_messages_reach_the_wire_in_place() {
1417        use crate::models::ChatMessageKind;
1418        let adapter = make_adapter().await;
1419        let mut nudge = ChatMessage::system("Reminder: plan mode is active.");
1420        nudge.kind = ChatMessageKind::RecoveryNudge;
1421        let messages = vec![ChatMessage::user("ok"), nudge];
1422        let body = adapter.build_request_body(&messages, &ModelConfig::default(), false, false);
1423
1424        let msgs = body["messages"].as_array().expect("messages array");
1425        let last = msgs.last().expect("non-empty");
1426        assert_eq!(last["role"], "system");
1427        assert!(
1428            last["content"]
1429                .as_str()
1430                .unwrap()
1431                .contains("plan mode is active"),
1432        );
1433    }
1434
1435    #[tokio::test]
1436    async fn ollama_request_body_omits_think_when_reasoning_none() {
1437        let adapter = make_adapter().await;
1438        let config = ModelConfig {
1439            reasoning: ReasoningLevel::None,
1440            ..Default::default()
1441        };
1442        let messages = vec![ChatMessage::user("hi")];
1443
1444        let body = adapter.build_request_body(&messages, &config, false, true);
1445        assert_eq!(body["think"], serde_json::json!(false));
1446    }
1447
1448    #[tokio::test]
1449    async fn ollama_request_body_preserves_registry_selected_web_tools() {
1450        let adapter = make_adapter().await;
1451        let config = ModelConfig {
1452            tools: ["web_fetch", "web_search"]
1453                .into_iter()
1454                .map(|name| {
1455                    serde_json::json!({
1456                        "type": "function",
1457                        "function": {
1458                            "name": name,
1459                            "description": "registered web tool",
1460                            "parameters": {"type": "object"}
1461                        }
1462                    })
1463                })
1464                .collect(),
1465            ..Default::default()
1466        };
1467
1468        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, false);
1469        let names: Vec<&str> = body["tools"]
1470            .as_array()
1471            .expect("tools array")
1472            .iter()
1473            .filter_map(|tool| {
1474                tool.pointer("/function/name")
1475                    .and_then(serde_json::Value::as_str)
1476            })
1477            .collect();
1478        assert_eq!(names, ["web_fetch", "web_search"]);
1479    }
1480
1481    #[tokio::test]
1482    async fn ollama_request_body_sets_think_true_for_low_reasoning() {
1483        let adapter = make_adapter().await;
1484        let config = ModelConfig {
1485            reasoning: ReasoningLevel::Low,
1486            ..Default::default()
1487        };
1488        let messages = vec![ChatMessage::user("hi")];
1489
1490        let body = adapter.build_request_body(&messages, &config, false, true);
1491        assert_eq!(body["think"], serde_json::json!(true));
1492    }
1493
1494    #[tokio::test]
1495    async fn ollama_request_body_sets_think_true_for_max_reasoning() {
1496        let adapter = make_adapter().await;
1497        let config = ModelConfig {
1498            reasoning: ReasoningLevel::Max,
1499            ..Default::default()
1500        };
1501        let messages = vec![ChatMessage::user("hi")];
1502
1503        let body = adapter.build_request_body(&messages, &config, false, true);
1504        assert_eq!(body["think"], serde_json::json!(true));
1505    }
1506
1507    #[tokio::test]
1508    async fn ollama_request_body_omits_think_when_unsupported() {
1509        // #122: a model that doesn't advertise the `thinking` capability must
1510        // not receive a `think` field at all — recent Ollama 400s on it.
1511        let adapter = make_adapter().await;
1512        let config = ModelConfig {
1513            reasoning: ReasoningLevel::High,
1514            ..Default::default()
1515        };
1516        let messages = vec![ChatMessage::user("hi")];
1517
1518        let body = adapter.build_request_body(&messages, &config, false, false);
1519        assert!(
1520            body.get("think").is_none(),
1521            "think must be omitted for a non-thinking model, got {:?}",
1522            body.get("think")
1523        );
1524    }
1525
1526    #[tokio::test]
1527    async fn ollama_request_body_emits_num_ctx_and_num_predict() {
1528        let adapter = make_adapter().await;
1529        let mut config = ModelConfig::default();
1530        config.set_backend_option("ollama".into(), "num_ctx".into(), "32768".into());
1531        config.set_backend_option("ollama".into(), "num_predict".into(), "8192".into());
1532
1533        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1534        assert_eq!(body["options"]["num_ctx"], serde_json::json!(32768));
1535        assert_eq!(body["options"]["num_predict"], serde_json::json!(8192));
1536    }
1537
1538    #[tokio::test]
1539    async fn ollama_request_body_omits_sizing_when_unset() {
1540        let adapter = make_adapter().await;
1541        let config = ModelConfig::default();
1542        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1543        // Unset → omitted entirely so Ollama uses its own defaults.
1544        assert!(body["options"].get("num_ctx").is_none());
1545        assert!(body["options"].get("num_predict").is_none());
1546    }
1547
1548    // --- /api/show model_info parsing (context window + dims) ---
1549
1550    #[test]
1551    fn context_length_prefers_architecture_prefix() {
1552        let mi = serde_json::json!({
1553            "general.architecture": "qwen2",
1554            "qwen2.context_length": 262_144,
1555            "qwen2.block_count": 28,
1556        });
1557        assert_eq!(super::context_length_from_model_info(&mi), Some(262_144));
1558    }
1559
1560    #[test]
1561    fn context_length_falls_back_to_any_suffix() {
1562        // No general.architecture, but a *.context_length key exists.
1563        let mi = serde_json::json!({ "llama.context_length": 131_072 });
1564        assert_eq!(super::context_length_from_model_info(&mi), Some(131_072));
1565    }
1566
1567    #[test]
1568    fn context_length_missing_is_none() {
1569        let mi = serde_json::json!({ "general.architecture": "qwen2" });
1570        assert_eq!(super::context_length_from_model_info(&mi), None);
1571    }
1572
1573    #[test]
1574    fn dims_parsed_for_gqa_model() {
1575        let mi = serde_json::json!({
1576            "general.architecture": "qwen2",
1577            "qwen2.block_count": 28,
1578            "qwen2.attention.head_count": 28,
1579            "qwen2.attention.head_count_kv": 4,
1580            "qwen2.embedding_length": 3584,
1581        });
1582        let dims = super::dims_from_model_info(&mi).unwrap();
1583        assert_eq!(dims.block_count, 28);
1584        assert_eq!(dims.head_count, 28);
1585        assert_eq!(dims.head_count_kv, 4);
1586        assert_eq!(dims.embedding_length, 3584);
1587    }
1588
1589    #[test]
1590    fn dims_head_count_kv_defaults_to_head_count() {
1591        // Non-GQA model: no head_count_kv key → assume KV heads == heads.
1592        let mi = serde_json::json!({
1593            "llama.block_count": 32,
1594            "llama.attention.head_count": 32,
1595            "llama.embedding_length": 4096,
1596        });
1597        let dims = super::dims_from_model_info(&mi).unwrap();
1598        assert_eq!(dims.head_count_kv, 32);
1599    }
1600
1601    #[test]
1602    fn dims_missing_required_is_none() {
1603        let mi = serde_json::json!({ "gptoss.block_count": 24 }); // missing the rest
1604        assert!(super::dims_from_model_info(&mi).is_none());
1605    }
1606
1607    #[test]
1608    fn gptoss_architecture_prefix_parsed() {
1609        let mi = serde_json::json!({
1610            "general.architecture": "gptoss",
1611            "gptoss.context_length": 131_072,
1612            "gptoss.block_count": 24,
1613            "gptoss.attention.head_count": 64,
1614            "gptoss.attention.head_count_kv": 8,
1615            "gptoss.embedding_length": 2880,
1616        });
1617        assert_eq!(super::context_length_from_model_info(&mi), Some(131_072));
1618        assert!(super::dims_from_model_info(&mi).is_some());
1619    }
1620
1621    /// gpt-oss models require `think` as a STRING enum (not bool).
1622    /// Sending a bool silently uses the default effort; sending the
1623    /// wrong shape to a non-gpt-oss model 400s. `think_for_ollama` gates
1624    /// on model name.
1625    async fn make_gpt_oss_adapter() -> OllamaAdapter {
1626        OllamaAdapter::new("gpt-oss:20b", Arc::new(BackendConfig::default()))
1627            .await
1628            .expect("adapter")
1629    }
1630
1631    #[tokio::test]
1632    async fn ollama_request_body_maps_output_schema_to_format() {
1633        let adapter = make_adapter().await;
1634        let config = ModelConfig {
1635            output_schema: Some(serde_json::json!({"type": "object"})),
1636            ..Default::default()
1637        };
1638        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1639        assert_eq!(body["format"]["type"], "object");
1640        // Absent -> no format key.
1641        let body = adapter.build_request_body(
1642            &[ChatMessage::user("hi")],
1643            &ModelConfig::default(),
1644            false,
1645            true,
1646        );
1647        assert!(body.get("format").is_none());
1648    }
1649
1650    #[tokio::test]
1651    async fn ollama_request_body_sets_think_low_for_gpt_oss_none() {
1652        let adapter = make_gpt_oss_adapter().await;
1653        let config = ModelConfig {
1654            reasoning: ReasoningLevel::None,
1655            ..Default::default()
1656        };
1657        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1658        // gpt-oss can't truly disable; None collapses to "low".
1659        assert_eq!(body["think"], serde_json::json!("low"));
1660    }
1661
1662    #[tokio::test]
1663    async fn ollama_request_body_sets_think_medium_for_gpt_oss_medium() {
1664        let adapter = make_gpt_oss_adapter().await;
1665        let config = ModelConfig {
1666            reasoning: ReasoningLevel::Medium,
1667            ..Default::default()
1668        };
1669        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1670        assert_eq!(body["think"], serde_json::json!("medium"));
1671    }
1672
1673    #[tokio::test]
1674    async fn ollama_request_body_sets_think_high_for_gpt_oss_max() {
1675        let adapter = make_gpt_oss_adapter().await;
1676        let config = ModelConfig {
1677            reasoning: ReasoningLevel::Max,
1678            ..Default::default()
1679        };
1680        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1681        // Max / High / XHigh all snap to the gpt-oss top tier "high".
1682        assert_eq!(body["think"], serde_json::json!("high"));
1683    }
1684
1685    #[tokio::test]
1686    async fn ollama_request_body_sets_think_high_for_gpt_oss_xhigh() {
1687        let adapter = make_gpt_oss_adapter().await;
1688        let config = ModelConfig {
1689            reasoning: ReasoningLevel::XHigh,
1690            ..Default::default()
1691        };
1692        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1693        assert_eq!(body["think"], serde_json::json!("high"));
1694    }
1695
1696    #[test]
1697    fn gpt_oss_effort_string_matches_prefix_case_insensitive() {
1698        assert!(uses_effort_string_think("gpt-oss:20b"));
1699        assert!(uses_effort_string_think("gpt-oss:120b-cloud"));
1700        assert!(uses_effort_string_think("GPT-OSS:20b"));
1701        assert!(!uses_effort_string_think("qwen3-coder:30b"));
1702        assert!(!uses_effort_string_think("gpt-4o"));
1703    }
1704
1705    #[test]
1706    fn map_ollama_done_reason_maps_known_and_preserves_unknown() {
1707        use super::{FinishReason, map_ollama_done_reason};
1708        assert_eq!(map_ollama_done_reason("stop"), FinishReason::Stop);
1709        assert_eq!(map_ollama_done_reason("length"), FinishReason::Length);
1710        assert_eq!(
1711            map_ollama_done_reason("load"),
1712            FinishReason::Other("load".to_string())
1713        );
1714    }
1715
1716    #[test]
1717    fn process_stream_chunk_captures_done_reason_and_saturates_tokens() {
1718        // #13: the terminal chunk's done_reason is recorded (was hardcoded None).
1719        // #49: token totals use saturating_add (here near usize::MAX).
1720        use super::{OllamaMessage, OllamaStreamChunk, StreamAccumulator};
1721        let mut acc = StreamAccumulator {
1722            content: String::new(),
1723            thinking: String::new(),
1724            tool_calls: Vec::new(),
1725            hide_reasoning_trace: false,
1726            prompt_tokens: 0,
1727            completion_tokens: 0,
1728            saw_usage: false,
1729            done_reason: None,
1730            saw_done: false,
1731            truncated: false,
1732        };
1733        let chunk = OllamaStreamChunk {
1734            message: OllamaMessage {
1735                role: "assistant".to_string(),
1736                content: String::new(),
1737                thinking: None,
1738                tool_calls: None,
1739            },
1740            done: true,
1741            prompt_eval_count: Some(usize::MAX),
1742            eval_count: Some(10),
1743            done_reason: Some("length".to_string()),
1744        };
1745        OllamaAdapter::process_stream_chunk(&chunk, None, &mut acc);
1746        assert_eq!(acc.done_reason.as_deref(), Some("length"));
1747        assert_eq!(acc.prompt_tokens, usize::MAX);
1748        assert_eq!(acc.completion_tokens, 10);
1749        // F54: real eval counts arrived → usage is reported (not None).
1750        assert!(acc.saw_usage);
1751        assert!(acc.usage().is_some());
1752        // #49: the total saturates instead of wrapping/panicking.
1753        assert_eq!(
1754            acc.prompt_tokens.saturating_add(acc.completion_tokens),
1755            usize::MAX
1756        );
1757    }
1758
1759    fn empty_accumulator() -> super::StreamAccumulator {
1760        super::StreamAccumulator {
1761            content: String::new(),
1762            thinking: String::new(),
1763            tool_calls: Vec::new(),
1764            hide_reasoning_trace: false,
1765            prompt_tokens: 0,
1766            completion_tokens: 0,
1767            saw_usage: false,
1768            done_reason: None,
1769            saw_done: false,
1770            truncated: false,
1771        }
1772    }
1773
1774    #[test]
1775    fn stream_usage_is_none_when_counts_absent_then_some_after_done() {
1776        // F54: a stream cut before the terminal `done` chunk (no eval counts)
1777        // must report `None` usage so the reducer keeps its estimate instead of
1778        // resetting the context gauge to zero. A real `done` flips it to `Some`.
1779        use super::{OllamaMessage, OllamaStreamChunk};
1780        let mut acc = empty_accumulator();
1781
1782        let content_chunk = OllamaStreamChunk {
1783            message: OllamaMessage {
1784                role: "assistant".to_string(),
1785                content: "hi".to_string(),
1786                thinking: None,
1787                tool_calls: None,
1788            },
1789            done: false,
1790            prompt_eval_count: None,
1791            eval_count: None,
1792            done_reason: None,
1793        };
1794        OllamaAdapter::process_stream_chunk(&content_chunk, None, &mut acc);
1795        assert!(
1796            acc.usage().is_none(),
1797            "a cut stream must not reset the gauge to a zero usage"
1798        );
1799
1800        let done_chunk = OllamaStreamChunk {
1801            message: OllamaMessage {
1802                role: "assistant".to_string(),
1803                content: String::new(),
1804                thinking: None,
1805                tool_calls: None,
1806            },
1807            done: true,
1808            prompt_eval_count: Some(120),
1809            eval_count: Some(8),
1810            done_reason: Some("stop".to_string()),
1811        };
1812        OllamaAdapter::process_stream_chunk(&done_chunk, None, &mut acc);
1813        let usage = acc
1814            .usage()
1815            .expect("usage present after a done chunk with counts");
1816        assert_eq!(usage.prompt_tokens, 120);
1817        assert_eq!(usage.completion_tokens, 8);
1818        assert_eq!(usage.total_tokens(), 128);
1819    }
1820
1821    #[test]
1822    fn closed_abnormally_until_terminal_done_chunk_seen() {
1823        // F56: a stream is abnormal until Ollama's terminal `done` chunk lands.
1824        use super::{OllamaMessage, OllamaStreamChunk};
1825        let mut acc = empty_accumulator();
1826        // Fresh / before any frame → abnormal (nothing terminal observed yet).
1827        assert!(acc.closed_abnormally());
1828
1829        // A content delta (done: false) is NOT the terminal frame.
1830        let content_chunk = OllamaStreamChunk {
1831            message: OllamaMessage {
1832                role: "assistant".to_string(),
1833                content: "partial".to_string(),
1834                thinking: None,
1835                tool_calls: None,
1836            },
1837            done: false,
1838            prompt_eval_count: None,
1839            eval_count: None,
1840            done_reason: None,
1841        };
1842        OllamaAdapter::process_stream_chunk(&content_chunk, None, &mut acc);
1843        assert!(
1844            acc.closed_abnormally(),
1845            "a stream cut before `done` must be flagged abnormal"
1846        );
1847
1848        // The terminal `done` chunk flips it to a clean completion.
1849        let done_chunk = OllamaStreamChunk {
1850            message: OllamaMessage {
1851                role: "assistant".to_string(),
1852                content: String::new(),
1853                thinking: None,
1854                tool_calls: None,
1855            },
1856            done: true,
1857            prompt_eval_count: Some(10),
1858            eval_count: Some(2),
1859            done_reason: Some("stop".to_string()),
1860        };
1861        OllamaAdapter::process_stream_chunk(&done_chunk, None, &mut acc);
1862        assert!(
1863            !acc.closed_abnormally(),
1864            "a `done` chunk completes the stream"
1865        );
1866    }
1867
1868    #[test]
1869    fn context_full_length_truncation_is_not_abnormal() {
1870        // CRUCIAL truncation-recovery guard: Ollama signals context-full as a
1871        // CLEAN terminal `done` chunk with `done_reason: "length"`. It must NOT
1872        // be misclassified as an abnormal close — `saw_done` is set, so the
1873        // adapter returns Ok with FinishReason::Length for compact-and-continue.
1874        use super::{FinishReason, OllamaMessage, OllamaStreamChunk, map_ollama_done_reason};
1875        let mut acc = empty_accumulator();
1876        let length_done = OllamaStreamChunk {
1877            message: OllamaMessage {
1878                role: "assistant".to_string(),
1879                content: "...".to_string(),
1880                thinking: None,
1881                tool_calls: None,
1882            },
1883            done: true,
1884            prompt_eval_count: Some(4096),
1885            eval_count: Some(512),
1886            done_reason: Some("length".to_string()),
1887        };
1888        OllamaAdapter::process_stream_chunk(&length_done, None, &mut acc);
1889        assert!(
1890            !acc.closed_abnormally(),
1891            "context-full Length truncation has a real `done` frame — not abnormal"
1892        );
1893        assert_eq!(
1894            acc.done_reason.as_deref().map(map_ollama_done_reason),
1895            Some(FinishReason::Length)
1896        );
1897    }
1898
1899    #[test]
1900    fn stream_frame_error_becomes_typed_provider_error() {
1901        // F53: a mid-stream `{"error":"..."}` frame must surface as a typed
1902        // ProviderError carrying the real message, not a generic
1903        // `ParseError("missing field `message`")`.
1904        use super::{BackendError, ModelError, parse_ollama_stream_frame};
1905        let err = parse_ollama_stream_frame(r#"{"error":"model requires more system memory"}"#)
1906            .expect_err("error frame must not parse as a chunk");
1907        match err {
1908            ModelError::Backend(BackendError::ProviderError {
1909                provider, message, ..
1910            }) => {
1911                assert_eq!(provider, "ollama");
1912                assert_eq!(message, "model requires more system memory");
1913            },
1914            other => panic!("expected ProviderError, got {other:?}"),
1915        }
1916    }
1917
1918    #[test]
1919    fn stream_frame_normal_chunk_still_parses() {
1920        // The error-frame guard must not disturb a normal content frame.
1921        use super::parse_ollama_stream_frame;
1922        let chunk = parse_ollama_stream_frame(
1923            r#"{"message":{"role":"assistant","content":"hello"},"done":false}"#,
1924        )
1925        .expect("normal frame parses");
1926        assert_eq!(chunk.message.content, "hello");
1927        assert!(!chunk.done);
1928    }
1929
1930    #[test]
1931    fn ollama_message_defaults_missing_content() {
1932        // F55: a frame that omits `content` (vs sending `""`) must still parse —
1933        // `content` defaults to "" rather than tearing down the whole stream.
1934        let chunk: super::OllamaStreamChunk = serde_json::from_str(
1935            r#"{"message":{"role":"assistant","thinking":"hmm"},"done":false}"#,
1936        )
1937        .expect("frame without content parses");
1938        assert_eq!(chunk.message.content, "");
1939        assert_eq!(chunk.message.thinking.as_deref(), Some("hmm"));
1940    }
1941
1942    /// Step 5h: Ollama doesn't cache, so the dynamic MERMAID.md suffix is
1943    /// concatenated onto the static system message with a `---` separator.
1944    /// Both halves reach the model in one system message payload.
1945    #[tokio::test]
1946    async fn ollama_request_body_concats_dynamic_suffix_to_system_message() {
1947        let adapter = make_adapter().await;
1948        let config = ModelConfig {
1949            system_prompt: Some("You are Mermaid.".to_string()),
1950            dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
1951            ..Default::default()
1952        };
1953        let messages = vec![ChatMessage::user("hi")];
1954
1955        let body = adapter.build_request_body(&messages, &config, false, true);
1956        let messages_arr = body["messages"].as_array().expect("messages array");
1957        assert_eq!(messages_arr[0]["role"], "system");
1958        let content = messages_arr[0]["content"].as_str().unwrap();
1959        assert!(content.contains("You are Mermaid."));
1960        assert!(content.contains("Project rule: always snake_case."));
1961        assert!(content.contains("---"));
1962    }
1963}