Skip to main content

mermaid_cli/providers/model/
ollama.rs

1//! Ollama provider wrapping `OllamaAdapter`.
2//!
3//! The adapter owns the wire format (NDJSON framing, gpt-oss
4//! reasoning dispatch, truncation marker, retry). The wrapper
5//! translates `ChatRequest` ↔ `ModelConfig` and bridges the
6//! adapter's legacy `StreamCallback` to the typed `StreamEvent`
7//! sink. Adapter-internals stay where they are; the architecture
8//! boundary is at `ModelProvider::chat`.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13
14use crate::domain::ChatRequest;
15use crate::models::adapters::ollama::{OllamaAdapter, OllamaModelInfo};
16use crate::models::adapters::ollama_sizing::{
17    NumCtxInputs, converge_num_ctx, default_ollama_num_predict, kv_bytes_per_token,
18    resolve_ollama_num_ctx,
19};
20use crate::models::{
21    BackendConfig, Model, ModelConfig, ModelError, ReasoningChunk, Result, StreamCallback,
22    StreamEvent as ModelStreamEvent,
23};
24use crate::runtime::{NewProviderProbe, RuntimeStore};
25
26use super::super::capabilities::Capabilities;
27use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
28use super::{
29    ContextSizing, ModelPlacement, ModelProvider, learn_output_cap, load_limits_from_db,
30    output_cap_from_error, probe_is_stale, retry_cap,
31};
32
33/// Ollama adapter fronted by `ModelProvider`.
34pub struct OllamaProvider {
35    adapter: OllamaAdapter,
36    capabilities: Capabilities,
37    /// Shared app `Config` so `build_model_config` can read Ollama
38    /// hardware options (`num_ctx`, `num_gpu`, `num_thread`, `numa`) at
39    /// call time. Before F11 these were silently dropped because the
40    /// wrapper built `ModelConfig` only from `ChatRequest` fields.
41    config: Arc<crate::app::Config>,
42    /// Cached `/api/show` probe (context window + dims + weight). Filled once per
43    /// process per model — the provider itself is cached by `ProviderFactory`, so
44    /// this fires a single network probe per model. Backed by the cross-session
45    /// `provider_probes` table. Probe *failures* are not cached (left empty for a
46    /// cheap retry next turn).
47    ctx_cell: tokio::sync::OnceCell<OllamaModelInfo>,
48}
49
50impl OllamaProvider {
51    /// Backward-compatible constructor that uses a default app config.
52    /// Call `with_app_config` instead when you have one available so
53    /// Ollama hardware options actually reach the adapter.
54    pub async fn new(model_name: &str, backend: Arc<BackendConfig>) -> Result<Self> {
55        Self::with_app_config(model_name, backend, Arc::new(crate::app::Config::default())).await
56    }
57
58    /// Construct with an explicit `app::Config` reference. Used by
59    /// `ProviderFactory::build_provider` so `config.ollama.{num_gpu,
60    /// num_ctx, num_thread, numa}` make it into the Ollama request's
61    /// `options` block.
62    pub async fn with_app_config(
63        model_name: &str,
64        backend: Arc<BackendConfig>,
65        config: Arc<crate::app::Config>,
66    ) -> Result<Self> {
67        let adapter = OllamaAdapter::new(model_name, backend).await?;
68        let capabilities = Capabilities::from_legacy(adapter.capabilities());
69        Ok(Self {
70            adapter,
71            capabilities,
72            config,
73            ctx_cell: tokio::sync::OnceCell::new(),
74        })
75    }
76
77    /// Probe the model's capabilities, cache-first. In-process `OnceCell` backed
78    /// by the cross-session `provider_probes` table; failures aren't cached.
79    async fn probe(&self) -> Option<OllamaModelInfo> {
80        self.ctx_cell
81            .get_or_try_init(|| async { self.load_probe().await.ok_or(()) })
82            .await
83            .ok()
84            .cloned()
85    }
86
87    async fn load_probe(&self) -> Option<OllamaModelInfo> {
88        let model = self.adapter.name().to_string();
89        if let Some(info) = load_probe_from_db(model.clone()).await {
90            return Some(info);
91        }
92        let info = self.adapter.show_model_info().await?;
93        save_probe_to_db(model, info.clone()).await;
94        Some(info)
95    }
96
97    /// Assemble the sizing inputs from the probe, host memory, config, and the
98    /// request's per-model override. Built here and in the effect layer from the
99    /// same (cached) sources so the effective window can't diverge between the
100    /// request and compaction.
101    async fn num_ctx_inputs(
102        &self,
103        info: &OllamaModelInfo,
104        override_num_ctx: Option<u32>,
105        override_offload: Option<bool>,
106    ) -> NumCtxInputs {
107        // Live `/context offload` toggle wins; otherwise the persisted default.
108        // The provider's `config` is frozen at startup (the factory never
109        // rebuilds), so the toggle rides on the request instead of `config`.
110        let allow_ram_offload = override_offload.unwrap_or(self.config.ollama.allow_ram_offload);
111        // Only fetch the budget we'll actually use: VRAM when keeping the model
112        // on the GPU (default), system RAM when offload is allowed.
113        let (vram_bytes, system_ram_bytes) = if allow_ram_offload {
114            (None, crate::utils::system_ram_bytes())
115        } else {
116            (crate::utils::gpu_vram_bytes().await, None)
117        };
118        NumCtxInputs {
119            model_max: info.context_length,
120            dims: info.dims,
121            model_weight_bytes: info.weight_bytes,
122            per_model_override: override_num_ctx,
123            global_num_ctx: self.config.ollama.num_ctx,
124            allow_ram_offload,
125            vram_bytes,
126            system_ram_bytes,
127            max_auto_cap: self.config.ollama.max_auto_num_ctx,
128            is_cloud: crate::ollama::is_cloud_model(self.adapter.name()),
129        }
130    }
131}
132
133#[async_trait]
134impl ModelProvider for OllamaProvider {
135    fn capabilities(&self) -> &Capabilities {
136        &self.capabilities
137    }
138
139    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
140        let info = self.probe().await.unwrap_or_default();
141        let inputs = self
142            .num_ctx_inputs(
143                &info,
144                request.ollama_num_ctx,
145                request.ollama_allow_ram_offload,
146            )
147            .await;
148        let model_max = inputs.model_max;
149        // Local Ollama imposes no per-response ceiling (num_predict is ours),
150        // but Ollama Cloud maps num_predict → max_tokens and 400s above the
151        // model's real cap. A cap learned from such a 400 lives in the limits
152        // cache; feed it forward so sizing computes min(window room, cap).
153        let max_output =
154            load_limits_from_db("ollama".to_string(), Model::name(&self.adapter).to_string())
155                .await
156                .and_then(|l| l.max_output_tokens);
157        match resolve_ollama_num_ctx(&inputs) {
158            Some(r) => ContextSizing {
159                model_max,
160                effective: Some(r.value),
161                source: Some(r.source),
162                max_output,
163            },
164            // No model_max and nothing configured → omit num_ctx (Ollama default).
165            None => ContextSizing {
166                model_max,
167                effective: None,
168                source: None,
169                max_output,
170            },
171        }
172    }
173
174    async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
175        let (vram, total) = self.adapter.model_placement().await?;
176        // A zero total means Ollama reported the model but not its footprint —
177        // can't judge placement, so leave it unknown rather than guess.
178        if total == 0 {
179            return None;
180        }
181        // On a spill, compute the largest num_ctx that would fit, from the model's
182        // KV cost (probe dims) and the *measured* overflow — the auto-converge
183        // target. `None` if it already fits or shrinking can't help.
184        let suggested_num_ctx = if vram < total {
185            let info = self.probe().await.unwrap_or_default();
186            current_num_ctx
187                .zip(info.dims)
188                .and_then(|(current, dims)| {
189                    let kv = kv_bytes_per_token(&dims)?;
190                    converge_num_ctx(current, vram, total, kv)
191                })
192                .map(|n| n as u32)
193        } else {
194            None
195        };
196        Some(ModelPlacement {
197            size_vram_bytes: vram,
198            total_bytes: total,
199            suggested_num_ctx,
200        })
201    }
202
203    async fn supports_vision(&self) -> Option<bool> {
204        Some(self.adapter.vision_supported().await)
205    }
206
207    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
208        // Resolve the effective window first (cache-first probe). Idempotent with
209        // the effect layer's call — both read the same cached probe + memory, so
210        // what we send as num_ctx matches what compaction assumes.
211        let sizing = self.resolve_context_window(&request).await;
212        let config =
213            build_model_config(&request, &self.config, sizing.effective, sizing.max_output);
214        // Ordered relay (F2): the adapter's sync callback pushes into an
215        // `UnboundedSender` (synchronous, FIFO). A single relay task drains
216        // into the bounded sink in order, avoiding the per-event `tokio::
217        // spawn` race that could deliver `Done` before prior tool calls.
218        let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
219        let callback = stream_callback_for(relay_tx.clone());
220
221        // Race adapter.chat against the cancellation token. When
222        // cancelled, the adapter's stream loop observes the sink
223        // closing (we drop `callback`) and exits at its next await.
224        // This is the crucial structural win vs. the old
225        // `check_interrupt` polling: the adapter doesn't need to
226        // know anything about turn IDs — the sink either drains or
227        // doesn't, and the tokens handle everything else.
228        let chat_fut = async {
229            match self
230                .adapter
231                .chat(&request.messages, &config, Some(callback.clone()))
232                .await
233            {
234                Ok(response) => Ok(response),
235                Err(err) => {
236                    // Learn-from-400: Ollama Cloud rejects a num_predict above
237                    // the model's real output cap and names the cap in the
238                    // body. Learn it (persisted — later turns size below it up
239                    // front), clamp, retry ONCE. A request that 400s streamed
240                    // no events, so the relay is untouched and reusable.
241                    let Some(cap) = output_cap_from_error(&err) else {
242                        return Err(err);
243                    };
244                    let sent = config
245                        .ollama_options()
246                        .num_predict
247                        .map_or(0, |v| v.max(0) as usize);
248                    if retry_cap(sent, cap).is_none() {
249                        return Err(err);
250                    }
251                    let model = Model::name(&self.adapter).to_string();
252                    learn_output_cap("ollama".to_string(), model.clone(), cap).await;
253                    let _ = relay_tx.send(StreamEvent::Status(format!(
254                        "{model} rejected the output budget; learned its {cap}-token cap and retrying"
255                    )));
256                    let retry_config =
257                        build_model_config(&request, &self.config, sizing.effective, Some(cap));
258                    self.adapter
259                        .chat(&request.messages, &retry_config, Some(callback.clone()))
260                        .await
261                },
262            }
263        };
264
265        let response = tokio::select! {
266            biased;
267            _ = ctx.token.cancelled() => {
268                // Terminal event for a cancelled turn comes from the
269                // runner's `drop_scope` once the `TurnScope` drains, so
270                // we neither emit `StreamEvent::Done` here nor surface
271                // an `UpstreamError`. `ModelError::Cancelled` is the
272                // sentinel the runner swallows.
273                return Err(ModelError::Cancelled);
274            },
275            r = chat_fut => r?,
276        };
277
278        // F3: the wrapper's `Done` is now the sole terminal event —
279        // the adapter no longer emits one from the callback. Carrying
280        // `provider_continuation` out of `ModelResponse` here is what
281        // lets multi-turn extended thinking round-trip.
282        let usage = response.usage.clone();
283        let provider_continuation = response.provider_continuation.clone();
284        let stop_reason = response.stop_reason.clone();
285        // Terminal Done through the ordered relay, then drain (see stream_bridge).
286        let _ = relay_tx.send(StreamEvent::Done {
287            usage: usage.clone(),
288            provider_continuation: provider_continuation.clone(),
289            stop_reason: stop_reason.clone(),
290        });
291        drop(relay_tx);
292        crate::utils::join_logged(relay_handle.take(), "stream_relay").await;
293
294        Ok(FinalResponse {
295            usage,
296            provider_continuation,
297            tool_calls: response.tool_calls.unwrap_or_default(),
298            stop_reason,
299        })
300    }
301}
302
303// ─── helpers ────────────────────────────────────────────────────────
304
305/// `num_ctx` is the resolved effective window (auto-fitted, or override/global);
306/// passing it here keeps a single source of truth — the direct `config.ollama.
307/// num_ctx` forward is gone because the resolver already considered it.
308/// `provider_max_output` is a known per-response ceiling (learned from an
309/// Ollama Cloud 400), bounding the derived `num_predict`.
310fn build_model_config(
311    request: &ChatRequest,
312    app_config: &crate::app::Config,
313    num_ctx: Option<usize>,
314    provider_max_output: Option<usize>,
315) -> ModelConfig {
316    let mut mc = ModelConfig {
317        model: request.model_id.clone(),
318        temperature: request.temperature,
319        max_tokens: request.max_tokens,
320        reasoning: request.reasoning,
321        system_prompt: Some(request.system_prompt.clone()),
322        dynamic_system_suffix: request.instructions.clone(),
323        tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
324        output_schema: request.output_schema.clone(),
325        ..Default::default()
326    };
327    // Effective context window (auto-fitted to memory / override / global).
328    if let Some(n) = num_ctx {
329        mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
330    }
331    // Output cap: AUTO (max_tokens == 0) gets the full room num_ctx leaves
332    // after the prompt; an explicit max_tokens is an exact cap bounded by that
333    // room. Without a cap Ollama generates unbounded and only stops when the
334    // window fills — the truncation bug. `None` = AUTO with an unknown window →
335    // omit num_predict so Ollama applies its own default.
336    if let Some(num_predict) = default_ollama_num_predict(
337        request.max_tokens,
338        num_ctx,
339        estimate_prompt_tokens(request),
340        provider_max_output,
341    ) {
342        mc.set_backend_option(
343            "ollama".into(),
344            "num_predict".into(),
345            num_predict.to_string(),
346        );
347    }
348
349    // F11: forward Ollama hardware options from the user's app config (num_ctx is
350    // now handled above via the resolver).
351    if let Some(v) = app_config.ollama.num_gpu {
352        mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
353    }
354    if let Some(v) = app_config.ollama.num_thread {
355        mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
356    }
357    if let Some(v) = app_config.ollama.numa {
358        mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
359    }
360    mc
361}
362
363/// Rough prompt-token estimate (≈4 chars/token) for bounding `num_predict`
364/// against the remaining room in `num_ctx`. Approximate by design — it only
365/// gates the output cap, never the prompt itself.
366fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
367    let chars = request.system_prompt.len()
368        + request.instructions.as_deref().map_or(0, str::len)
369        + request
370            .messages
371            .iter()
372            .map(|m| m.content.len())
373            .sum::<usize>();
374    chars / 4
375}
376
377/// Load a cached probe from `provider_probes` (within TTL). Runs the blocking
378/// SQLite read off the async runtime. Best-effort: any failure → `None`.
379async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
380    tokio::task::spawn_blocking(move || {
381        let store = RuntimeStore::open_default().ok()?;
382        let rec = store
383            .provider_probes()
384            .get("ollama", &model, "context_probe")
385            .ok()??;
386        if probe_is_stale(&rec.probed_at) {
387            return None;
388        }
389        serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
390    })
391    .await
392    .ok()
393    .flatten()
394}
395
396/// Persist a probe to `provider_probes` for subsequent sessions. Best-effort.
397async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
398    let _ = tokio::task::spawn_blocking(move || -> Option<()> {
399        let value = serde_json::to_string(&info).ok()?;
400        let store = RuntimeStore::open_default().ok()?;
401        store
402            .provider_probes()
403            .upsert(NewProviderProbe {
404                provider: "ollama".into(),
405                model_id: model,
406                capability_key: "context_probe".into(),
407                capability_value: value,
408                confidence: "probed".into(),
409                error: None,
410            })
411            .ok()?;
412        Some(())
413    })
414    .await;
415}
416
417/// Build a `StreamCallback` that forwards `ModelStreamEvent`s from the
418/// adapter into an `UnboundedSender<StreamEvent>` (ordered relay). The
419/// caller wires that sender to a bounded sink via
420/// `stream_bridge::ordered_relay`; this keeps event delivery FIFO even
421/// though the adapter calls us from a sync context.
422fn stream_callback_for(sink: tokio::sync::mpsc::UnboundedSender<StreamEvent>) -> StreamCallback {
423    Arc::new(move |event: ModelStreamEvent| {
424        let mapped = match event {
425            ModelStreamEvent::Text(s) => StreamEvent::Text(s),
426            ModelStreamEvent::Reasoning(chunk) => StreamEvent::Reasoning(ReasoningChunk {
427                text: chunk.text,
428                signature: chunk.signature,
429            }),
430            ModelStreamEvent::ToolCall(tc) => StreamEvent::ToolCall(tc),
431            ModelStreamEvent::Status(s) => StreamEvent::Status(s),
432            // No adapter emits `Done` through this callback — the wrapper
433            // sends the authoritative terminal `Done` built from the
434            // returned `ModelResponse` (F3). Map defensively without
435            // inventing usage (the old placeholder misfiled everything
436            // as completion tokens).
437            ModelStreamEvent::Done { .. } => StreamEvent::Done {
438                usage: None,
439                provider_continuation: None,
440                stop_reason: None,
441            },
442        };
443        // Synchronous send preserves ordering. Ignore errors — the
444        // receiver has closed means the turn is already gone.
445        let _ = sink.send(mapped);
446    })
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn build_model_config_maps_request_fields() {
455        let req = ChatRequest {
456            model_id: "ollama/test".to_string(),
457            messages: vec![],
458            system_prompt: "sys".to_string(),
459            instructions: Some("instructions text".to_string()),
460            reasoning: crate::models::ReasoningLevel::High,
461            temperature: 0.3,
462            max_tokens: 2048,
463            tools: vec![],
464
465            ollama_num_ctx: None,
466            ollama_allow_ram_offload: None,
467            resolved_context_window: None,
468            resolved_max_output: None,
469            output_schema: None,
470            suppress_auto_compact: false,
471        };
472        let app_cfg = crate::app::Config::default();
473        let cfg = build_model_config(&req, &app_cfg, None, None);
474        assert_eq!(cfg.model, "ollama/test");
475        assert_eq!(cfg.temperature, 0.3);
476        assert_eq!(cfg.max_tokens, 2048);
477        assert_eq!(cfg.reasoning, crate::models::ReasoningLevel::High);
478        assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
479        assert_eq!(
480            cfg.dynamic_system_suffix.as_deref(),
481            Some("instructions text")
482        );
483    }
484
485    /// F11 regression guard: Ollama hardware options in the user's app config
486    /// must land in the ModelConfig's backend_options so the adapter's
487    /// `build_request_body` emits them under `options`. `num_ctx` now arrives via
488    /// the resolver param (not a direct config forward), and `num_predict` is
489    /// always derived.
490    #[test]
491    fn build_model_config_forwards_ollama_hardware_options() {
492        let req = ChatRequest {
493            model_id: "ollama/test".to_string(),
494            messages: vec![],
495            system_prompt: "sys".to_string(),
496            instructions: None,
497            reasoning: crate::models::ReasoningLevel::Medium,
498            temperature: 0.7,
499            max_tokens: 4096,
500            tools: vec![],
501
502            ollama_num_ctx: None,
503            ollama_allow_ram_offload: None,
504            resolved_context_window: None,
505            resolved_max_output: None,
506            output_schema: None,
507            suppress_auto_compact: false,
508        };
509        let mut app_cfg = crate::app::Config::default();
510        app_cfg.ollama.num_gpu = Some(10);
511        app_cfg.ollama.num_thread = Some(8);
512        app_cfg.ollama.numa = Some(true);
513
514        // The effective num_ctx (8192) is passed in by the resolver.
515        let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
516        let opts = cfg.ollama_options();
517        assert_eq!(opts.num_ctx, Some(8192));
518        assert_eq!(opts.num_gpu, Some(10));
519        assert_eq!(opts.num_thread, Some(8));
520        assert_eq!(opts.numa, Some(true));
521        assert!(opts.num_predict.is_some(), "num_predict is always derived");
522    }
523
524    /// An explicit `max_tokens` is an exact cap, bounded by the room left in
525    /// num_ctx (no reasoning reserve added — hard means hard).
526    #[test]
527    fn build_model_config_derives_num_predict() {
528        let req = ChatRequest {
529            model_id: "ollama/test".to_string(),
530            messages: vec![],
531            system_prompt: String::new(),
532            instructions: None,
533            reasoning: crate::models::ReasoningLevel::Max,
534            temperature: 0.7,
535            max_tokens: 4096,
536            tools: vec![],
537
538            ollama_num_ctx: None,
539            ollama_allow_ram_offload: None,
540            resolved_context_window: None,
541            resolved_max_output: None,
542            output_schema: None,
543            suppress_auto_compact: false,
544        };
545        let cfg = build_model_config(&req, &crate::app::Config::default(), Some(131_072), None);
546        // Exactly the 4096 cap; plenty of room in a 131072 window.
547        assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
548    }
549
550    /// The minimax incident, recomputed: a learned provider cap bounds the
551    /// AUTO num_predict below the full window room, so the retry (and every
552    /// later turn) sends a value Ollama Cloud accepts.
553    #[test]
554    fn build_model_config_caps_num_predict_at_learned_ceiling() {
555        let req = ChatRequest {
556            model_id: "ollama/minimax-m3:cloud".to_string(),
557            messages: vec![],
558            system_prompt: String::new(),
559            instructions: None,
560            reasoning: crate::models::ReasoningLevel::Medium,
561            temperature: 0.7,
562            max_tokens: 0, // AUTO — the incident's configuration
563            tools: vec![],
564
565            ollama_num_ctx: None,
566            ollama_allow_ram_offload: None,
567            resolved_context_window: None,
568            resolved_max_output: None,
569            output_schema: None,
570            suppress_auto_compact: false,
571        };
572        let app_cfg = crate::app::Config::default();
573        // Without a learned cap, AUTO hands over the full window room —
574        // 524_288 minus margin — which Ollama Cloud 400s for minimax-m3.
575        let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
576        assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
577        // With the learned 131_072 cap, sizing stays at the ceiling.
578        let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
579        assert_eq!(capped.ollama_options().num_predict, Some(131_072));
580    }
581
582    #[tokio::test]
583    async fn stream_callback_forwards_text_event() {
584        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
585        let cb = stream_callback_for(tx);
586        cb(ModelStreamEvent::Text("hello".to_string()));
587        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
588            .await
589            .expect("recv")
590            .expect("sender alive");
591        match recv {
592            StreamEvent::Text(s) => assert_eq!(s, "hello"),
593            _ => panic!("wrong variant"),
594        }
595    }
596
597    #[tokio::test]
598    async fn stream_callback_forwards_status_notice() {
599        // The autostart notice rides the same ordered relay as content
600        // events; the bridge must map it 1:1 so it reaches the effect
601        // layer (→ Msg::TransientStatus → system line / stderr).
602        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
603        let cb = stream_callback_for(tx);
604        cb(ModelStreamEvent::Status(
605            "Starting the local Ollama server…".to_string(),
606        ));
607        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
608            .await
609            .expect("recv")
610            .expect("sender alive");
611        match recv {
612            StreamEvent::Status(s) => assert!(s.contains("Starting")),
613            _ => panic!("wrong variant"),
614        }
615    }
616
617    #[tokio::test]
618    async fn stream_callback_done_never_invents_usage() {
619        // The wrapper's terminal Done (built from ModelResponse) is the only
620        // authoritative usage carrier; a callback Done must map to None
621        // rather than misfiling its bare count as completion tokens.
622        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
623        let cb = stream_callback_for(tx);
624        cb(ModelStreamEvent::Done { tokens: 42 });
625        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
626            .await
627            .expect("recv")
628            .expect("sender");
629        match recv {
630            StreamEvent::Done { usage, .. } => assert!(usage.is_none()),
631            _ => panic!("wrong variant"),
632        }
633    }
634}