Skip to main content

uni_xervo/provider/
mistralrs.rs

1use crate::api::{ModelAliasSpec, ModelTask};
2use crate::error::{Result, RuntimeError};
3use crate::traits::{
4    ContentBlock, DocExtractOptions, DocExtractResult, DocumentExtractionModel, EmbedResult,
5    EmbeddingModel, GenerationOptions, GenerationResult, GeneratorModel, ImageInput,
6    LoadedModelHandle, Message, MessageRole, ModelInfo, ModelProvider, ProviderCapabilities,
7    ProviderHealth, TokenUsage,
8};
9use async_trait::async_trait;
10use mistralrs::{
11    AutoDeviceMapParams, DeviceMapSetting, EmbeddingModelBuilder, EmbeddingRequestBuilder,
12    GgufModelBuilder, IsqType, Model, ModelDType, PagedAttentionMetaBuilder, RequestBuilder,
13    TextMessageRole, TextModelBuilder, UqffEmbeddingModelBuilder, UqffMultimodalModelBuilder,
14    UqffTextModelBuilder,
15};
16use serde::Deserialize;
17use std::path::PathBuf;
18use std::sync::Arc;
19
20/// Local inference provider using the mistral.rs engine.
21///
22/// Supports HuggingFace models with optional ISQ (in-situ quantization)
23/// for both embedding and text generation tasks.
24pub struct LocalMistralRsProvider;
25
26impl LocalMistralRsProvider {
27    pub fn new() -> Self {
28        Self
29    }
30
31    /// Set `HF_HOME` to our unified cache root before the first mistralrs load.
32    ///
33    /// mistralrs-core stores its HF cache handle in a process-global `OnceLock<Cache>`
34    /// (`GLOBAL_HF_CACHE`) that is initialised exactly once — from `HF_HOME` at the
35    /// time of the first model load.  The per-builder `from_hf_cache_path()` API feeds
36    /// into the same `get_or_init` call and is therefore silently ignored on every load
37    /// after the first one.
38    ///
39    /// Setting `HF_HOME` here (before any builder `.build()` call) ensures the
40    /// `OnceLock` captures our directory.  Subsequent calls are no-ops because the env
41    /// var is already set and `OnceLock` is already initialised.
42    fn init_hf_cache() {
43        let cache_root = crate::cache::resolve_provider_cache_root("mistralrs");
44        // SAFETY: single-threaded with respect to the first mistralrs load; the
45        // OnceLock guarantees only the first initialisation matters.
46        unsafe {
47            std::env::set_var("HF_HOME", &cache_root);
48        }
49    }
50}
51
52impl Default for LocalMistralRsProvider {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58#[async_trait]
59impl ModelProvider for LocalMistralRsProvider {
60    fn provider_id(&self) -> &'static str {
61        "local/mistralrs"
62    }
63
64    fn capabilities(&self) -> ProviderCapabilities {
65        ProviderCapabilities {
66            supported_tasks: vec![
67                ModelTask::Embed,
68                ModelTask::Generate,
69                ModelTask::DocumentExtract,
70            ],
71        }
72    }
73
74    async fn warmup(&self) -> Result<()> {
75        Self::init_hf_cache();
76        Ok(())
77    }
78
79    async fn load(&self, spec: &ModelAliasSpec) -> Result<LoadedModelHandle> {
80        // Best-effort: set HF_HOME before the first mistralrs OnceLock init.
81        // No-op if warmup() already ran or if a previous load already set it.
82        Self::init_hf_cache();
83
84        let has_options = match &spec.options {
85            serde_json::Value::Null => false,
86            serde_json::Value::Object(map) => !map.is_empty(),
87            _ => true,
88        };
89
90        let opts: MistralRsOptions = if has_options {
91            serde_json::from_value(spec.options.clone())
92                .map_err(|e| RuntimeError::Config(format!("Invalid mistralrs options: {}", e)))?
93        } else {
94            MistralRsOptions::default()
95        };
96
97        match spec.task {
98            ModelTask::Embed => self.load_embedding(spec, &opts).await,
99            ModelTask::Generate => self.load_generator(spec, &opts).await,
100            ModelTask::DocumentExtract => self.load_document_extractor(spec, &opts).await,
101            ModelTask::Raw => Err(RuntimeError::CapabilityMismatch(
102                "mistralrs provider does not support task Raw".to_string(),
103            )),
104            _ => Err(RuntimeError::CapabilityMismatch(format!(
105                "mistralrs provider does not support task {:?}",
106                spec.task
107            ))),
108        }
109    }
110
111    async fn health(&self) -> ProviderHealth {
112        ProviderHealth::Healthy
113    }
114}
115
116impl LocalMistralRsProvider {
117    async fn load_embedding(
118        &self,
119        spec: &ModelAliasSpec,
120        opts: &MistralRsOptions,
121    ) -> Result<LoadedModelHandle> {
122        tracing::info!(model_id = %spec.model_id, "Loading mistralrs embedding model");
123
124        // When gguf_files is set, model_id is treated as the GGUF directory path.
125        let model = if let Some(files) = &opts.gguf_files {
126            if opts.dtype.is_some() {
127                tracing::debug!("dtype option ignored for GGUF models");
128            }
129            let mut builder = GgufModelBuilder::new(spec.model_id.clone(), files.clone());
130
131            if let Some(ref chat_tmpl) = opts.chat_template {
132                builder = builder.with_chat_template(chat_tmpl.clone());
133            }
134            if let Some(ref tok_json) = opts.tokenizer_json {
135                builder = builder.with_tokenizer_json(tok_json.clone());
136            }
137            builder = builder.with_logging();
138
139            builder.build().await.map_err(|e| {
140                RuntimeError::Load(format!(
141                    "Failed to build mistralrs GGUF embedding model: {}",
142                    e
143                ))
144            })?
145        } else {
146            let mut builder = if let Some(files) = &opts.uqff_files {
147                let paths: Vec<PathBuf> = files.iter().map(PathBuf::from).collect();
148                UqffEmbeddingModelBuilder::new(&spec.model_id, paths).into_inner()
149            } else {
150                EmbeddingModelBuilder::new(&spec.model_id)
151            };
152
153            let dtype = resolve_model_dtype(opts)?;
154            builder = builder.with_dtype(dtype);
155
156            if opts.uqff_files.is_none()
157                && let Some(ref isq_str) = opts.isq
158            {
159                let isq = parse_isq_type(isq_str)?;
160                builder = builder.with_isq(isq);
161            }
162
163            if opts.force_cpu {
164                builder = builder.with_force_cpu();
165            }
166
167            if let Some(ref rev) = spec.revision {
168                builder = builder.with_hf_revision(rev);
169            }
170
171            if let Some(max_seqs) = opts.max_num_seqs {
172                builder = builder.with_max_num_seqs(max_seqs);
173            }
174
175            if let Some(setting) = build_device_map_setting_text(opts) {
176                builder = builder.with_device_mapping(setting);
177            }
178
179            if let Some(ref tok_json) = opts.tokenizer_json {
180                builder = builder.with_tokenizer_json(tok_json);
181            }
182
183            builder = builder.with_logging();
184
185            builder.build().await.map_err(|e| {
186                RuntimeError::Load(format!("Failed to build mistralrs embedding model: {}", e))
187            })?
188        };
189
190        let dimensions = match opts.embedding_dimensions {
191            Some(d) => d,
192            None => {
193                tracing::info!("Probing embedding dimensions with test input");
194                let probe = model.generate_embedding("probe").await.map_err(|e| {
195                    RuntimeError::Load(format!("Failed to probe embedding dimensions: {}", e))
196                })?;
197                validate_embeddings(std::slice::from_ref(&probe)).map_err(|e| {
198                    RuntimeError::Load(format!(
199                        "Probe returned invalid values: {e}. Try dtype: \"f32\""
200                    ))
201                })?;
202                probe.len() as u32
203            }
204        };
205
206        tracing::info!(
207            model_id = %spec.model_id,
208            dimensions,
209            "mistralrs embedding model loaded"
210        );
211
212        let service = MistralRsEmbeddingService {
213            model,
214            model_id: spec.model_id.clone(),
215            dimensions,
216        };
217
218        let handle: Arc<dyn EmbeddingModel> = Arc::new(service);
219        Ok(Arc::new(handle) as LoadedModelHandle)
220    }
221
222    async fn load_generator(
223        &self,
224        spec: &ModelAliasSpec,
225        opts: &MistralRsOptions,
226    ) -> Result<LoadedModelHandle> {
227        let pipeline = opts.pipeline.as_deref().unwrap_or("text");
228        match pipeline {
229            "text" => self.load_text_generator(spec, opts).await,
230            "vision" => self.load_vision_generator(spec, opts).await,
231            "diffusion" => self.load_diffusion_generator(spec, opts).await,
232            "speech" => self.load_speech_generator(spec, opts).await,
233            _ => Err(RuntimeError::Config(format!(
234                "Unknown pipeline '{}'. Valid: text, vision, diffusion, speech",
235                pipeline
236            ))),
237        }
238    }
239
240    async fn load_text_generator(
241        &self,
242        spec: &ModelAliasSpec,
243        opts: &MistralRsOptions,
244    ) -> Result<LoadedModelHandle> {
245        tracing::info!(model_id = %spec.model_id, "Loading mistralrs text generator model");
246
247        let model = if let Some(files) = &opts.gguf_files {
248            if opts.dtype.is_some() {
249                tracing::debug!("dtype option ignored for GGUF models");
250            }
251            let mut builder = GgufModelBuilder::new(spec.model_id.clone(), files.clone());
252
253            if let Some(ref chat_tmpl) = opts.chat_template {
254                builder = builder.with_chat_template(chat_tmpl.clone());
255            }
256            if let Some(ref tok_json) = opts.tokenizer_json {
257                builder = builder.with_tokenizer_json(tok_json.clone());
258            }
259            if opts.paged_attention {
260                let paged_cfg = PagedAttentionMetaBuilder::default().build().map_err(|e| {
261                    RuntimeError::Load(format!("Failed to configure paged attention: {}", e))
262                })?;
263                builder = builder.with_paged_attn(paged_cfg);
264            }
265            builder = builder.with_logging();
266
267            builder.build().await.map_err(|e| {
268                RuntimeError::Load(format!(
269                    "Failed to build mistralrs GGUF generator model: {}",
270                    e
271                ))
272            })?
273        } else {
274            let mut builder = if let Some(files) = &opts.uqff_files {
275                let paths: Vec<PathBuf> = files.iter().map(PathBuf::from).collect();
276                UqffTextModelBuilder::new(&spec.model_id, paths).into_inner()
277            } else {
278                TextModelBuilder::new(&spec.model_id)
279            };
280
281            let dtype = resolve_model_dtype(opts)?;
282            builder = builder.with_dtype(dtype);
283
284            if opts.uqff_files.is_none()
285                && let Some(ref isq_str) = opts.isq
286            {
287                let isq = parse_isq_type(isq_str)?;
288                builder = builder.with_isq(isq);
289            }
290
291            if opts.force_cpu {
292                builder = builder.with_force_cpu();
293            }
294
295            if let Some(ref rev) = spec.revision {
296                builder = builder.with_hf_revision(rev);
297            }
298
299            if opts.paged_attention {
300                let paged_cfg = PagedAttentionMetaBuilder::default().build().map_err(|e| {
301                    RuntimeError::Load(format!("Failed to configure paged attention: {}", e))
302                })?;
303                builder = builder.with_paged_attn(paged_cfg);
304            }
305
306            if let Some(ref chat_tmpl) = opts.chat_template {
307                builder = builder.with_chat_template(chat_tmpl);
308            }
309
310            if let Some(ref tok_json) = opts.tokenizer_json {
311                builder = builder.with_tokenizer_json(tok_json);
312            }
313
314            if let Some(max_seqs) = opts.max_num_seqs {
315                builder = builder.with_max_num_seqs(max_seqs);
316            }
317
318            if let Some(setting) = build_device_map_setting_text(opts) {
319                builder = builder.with_device_mapping(setting);
320            }
321
322            builder = builder.with_logging();
323
324            builder.build().await.map_err(|e| {
325                RuntimeError::Load(format!("Failed to build mistralrs generator model: {}", e))
326            })?
327        };
328
329        tracing::info!(model_id = %spec.model_id, "mistralrs generator model loaded");
330
331        let service = MistralRsGeneratorService {
332            model,
333            model_id: spec.model_id.clone(),
334        };
335
336        let handle: Arc<dyn GeneratorModel> = Arc::new(service);
337        Ok(Arc::new(handle) as LoadedModelHandle)
338    }
339
340    async fn build_vision_service(
341        &self,
342        spec: &ModelAliasSpec,
343        opts: &MistralRsOptions,
344    ) -> Result<Arc<dyn GeneratorModel>> {
345        use mistralrs::MultimodalModelBuilder;
346
347        if opts.gguf_files.is_some() {
348            return Err(RuntimeError::Config(
349                "GGUF is not supported for the vision pipeline".to_string(),
350            ));
351        }
352
353        tracing::info!(model_id = %spec.model_id, "Loading mistralrs vision generator model");
354
355        let mut builder = if let Some(files) = &opts.uqff_files {
356            let paths: Vec<PathBuf> = files.iter().map(PathBuf::from).collect();
357            UqffMultimodalModelBuilder::new(&spec.model_id, paths).into_inner()
358        } else {
359            MultimodalModelBuilder::new(&spec.model_id)
360        };
361        let dtype = resolve_model_dtype(opts)?;
362        builder = builder.with_dtype(dtype);
363
364        if opts.uqff_files.is_none()
365            && let Some(ref isq_str) = opts.isq
366        {
367            let isq = parse_isq_type(isq_str)?;
368            builder = builder.with_isq(isq);
369        }
370        if opts.force_cpu {
371            builder = builder.with_force_cpu();
372        }
373        if let Some(ref rev) = spec.revision {
374            builder = builder.with_hf_revision(rev);
375        }
376        if opts.paged_attention {
377            let paged_cfg = PagedAttentionMetaBuilder::default().build().map_err(|e| {
378                RuntimeError::Load(format!("Failed to configure paged attention: {}", e))
379            })?;
380            builder = builder.with_paged_attn(paged_cfg);
381        }
382        if let Some(ref chat_tmpl) = opts.chat_template {
383            builder = builder.with_chat_template(chat_tmpl);
384        }
385        if let Some(ref tok_json) = opts.tokenizer_json {
386            builder = builder.with_tokenizer_json(tok_json);
387        }
388        if let Some(max_seqs) = opts.max_num_seqs {
389            builder = builder.with_max_num_seqs(max_seqs);
390        }
391        if let Some(setting) = build_device_map_setting_multimodal(opts) {
392            builder = builder.with_device_mapping(setting);
393        }
394        builder = builder.with_logging();
395
396        let model = builder.build().await.map_err(|e| {
397            RuntimeError::Load(format!("Failed to build mistralrs vision model: {}", e))
398        })?;
399
400        tracing::info!(model_id = %spec.model_id, "mistralrs vision model loaded");
401
402        let service = MistralRsVisionService {
403            model,
404            model_id: spec.model_id.clone(),
405        };
406        Ok(Arc::new(service) as Arc<dyn GeneratorModel>)
407    }
408
409    async fn load_vision_generator(
410        &self,
411        spec: &ModelAliasSpec,
412        opts: &MistralRsOptions,
413    ) -> Result<LoadedModelHandle> {
414        let handle = self.build_vision_service(spec, opts).await?;
415        Ok(Arc::new(handle) as LoadedModelHandle)
416    }
417
418    async fn load_document_extractor(
419        &self,
420        spec: &ModelAliasSpec,
421        opts: &MistralRsOptions,
422    ) -> Result<LoadedModelHandle> {
423        // Document extraction (olmOCR-2 and similar) always runs on the vision
424        // pipeline, regardless of any `pipeline` option the caller set.
425        let generator = self.build_vision_service(spec, opts).await?;
426        let style_str = spec
427            .options
428            .get("style")
429            .and_then(|v| v.as_str())
430            .unwrap_or("olmocr");
431        let style = crate::doc_parse::style_from_str(style_str).ok_or_else(|| {
432            RuntimeError::Config(format!(
433                "Document extractor '{}' has unknown `style` value '{style_str}'; \
434                 expected one of: granite-docling, mineru, olmocr",
435                spec.alias
436            ))
437        })?;
438        let extractor = MistralRsDocumentExtractor {
439            generator,
440            model_id: spec.model_id.clone(),
441            style,
442        };
443        let handle: Arc<dyn DocumentExtractionModel> = Arc::new(extractor);
444        Ok(Arc::new(handle) as LoadedModelHandle)
445    }
446
447    async fn load_diffusion_generator(
448        &self,
449        spec: &ModelAliasSpec,
450        opts: &MistralRsOptions,
451    ) -> Result<LoadedModelHandle> {
452        use mistralrs::{DiffusionLoaderType, DiffusionModelBuilder};
453
454        let loader_type = match opts.diffusion_loader_type.as_deref().unwrap_or("flux") {
455            "flux" => DiffusionLoaderType::Flux,
456            "flux_offloaded" => DiffusionLoaderType::FluxOffloaded,
457            other => {
458                return Err(RuntimeError::Config(format!(
459                    "Unknown diffusion_loader_type '{}'. Valid: flux, flux_offloaded",
460                    other
461                )));
462            }
463        };
464
465        tracing::info!(model_id = %spec.model_id, "Loading mistralrs diffusion model");
466
467        let mut builder = DiffusionModelBuilder::new(&spec.model_id, loader_type);
468        if opts.force_cpu {
469            builder = builder.with_force_cpu();
470        }
471        let dtype = resolve_model_dtype(opts)?;
472        builder = builder.with_dtype(dtype);
473        builder = builder.with_logging();
474
475        let model = builder.build().await.map_err(|e| {
476            RuntimeError::Load(format!("Failed to build mistralrs diffusion model: {}", e))
477        })?;
478
479        tracing::info!(model_id = %spec.model_id, "mistralrs diffusion model loaded");
480
481        let service = MistralRsDiffusionService {
482            model,
483            model_id: spec.model_id.clone(),
484        };
485        let handle: Arc<dyn GeneratorModel> = Arc::new(service);
486        Ok(Arc::new(handle) as LoadedModelHandle)
487    }
488
489    async fn load_speech_generator(
490        &self,
491        spec: &ModelAliasSpec,
492        opts: &MistralRsOptions,
493    ) -> Result<LoadedModelHandle> {
494        use mistralrs::{SpeechLoaderType, SpeechModelBuilder};
495
496        let loader_type = match opts.speech_loader_type.as_deref().unwrap_or("dia") {
497            "dia" => SpeechLoaderType::Dia,
498            other => {
499                return Err(RuntimeError::Config(format!(
500                    "Unknown speech_loader_type '{}'. Valid: dia",
501                    other
502                )));
503            }
504        };
505
506        tracing::info!(model_id = %spec.model_id, "Loading mistralrs speech model");
507
508        let mut builder = SpeechModelBuilder::new(&spec.model_id, loader_type);
509        if opts.force_cpu {
510            builder = builder.with_force_cpu();
511        }
512        let dtype = resolve_model_dtype(opts)?;
513        builder = builder.with_dtype(dtype);
514        builder = builder.with_logging();
515
516        let model = builder.build().await.map_err(|e| {
517            RuntimeError::Load(format!("Failed to build mistralrs speech model: {}", e))
518        })?;
519
520        tracing::info!(model_id = %spec.model_id, "mistralrs speech model loaded");
521
522        let service = MistralRsSpeechService {
523            model,
524            model_id: spec.model_id.clone(),
525        };
526        let handle: Arc<dyn GeneratorModel> = Arc::new(service);
527        Ok(Arc::new(handle) as LoadedModelHandle)
528    }
529}
530
531// ---------------------------------------------------------------------------
532// Configuration
533// ---------------------------------------------------------------------------
534
535#[derive(Deserialize, Default)]
536#[serde(deny_unknown_fields)]
537struct MistralRsOptions {
538    /// ISQ quantization type, e.g. "Q4K", "Q8_0"
539    isq: Option<String>,
540    /// Force CPU inference (default: false)
541    #[serde(default)]
542    force_cpu: bool,
543    /// Enable paged attention (default: false)
544    #[serde(default)]
545    paged_attention: bool,
546    /// Maximum number of sequences for batching
547    max_num_seqs: Option<usize>,
548    /// Override chat template
549    chat_template: Option<String>,
550    /// Override tokenizer JSON path
551    tokenizer_json: Option<String>,
552    /// Override embedding dimensions (probed at load if absent)
553    embedding_dimensions: Option<u32>,
554    /// List of GGUF filenames (enables GGUF mode)
555    gguf_files: Option<Vec<String>>,
556    /// UQFF (mistralrs pre-quantized) filenames or paths.
557    ///
558    /// For HuggingFace UQFF repos, only the first shard needs to be named
559    /// (e.g. "q4k-0.uqff"); remaining shards are auto-discovered. The
560    /// quantization variant is selected by which file is named (q4k vs q5k
561    /// vs afq8 etc.). UQFF skips the bf16-then-quantize load flow and is
562    /// the practical path for fitting larger multimodal models on small
563    /// GPUs. Mutually exclusive with `gguf_files` and `isq` (validation
564    /// rejects the combinations). Honored on text, vision, and embedding
565    /// pipelines.
566    uqff_files: Option<Vec<String>>,
567    /// Model data type: "auto", "f16", "bf16", "f32"
568    dtype: Option<String>,
569    /// Pipeline type: "text" (default), "vision", "diffusion", "speech"
570    pipeline: Option<String>,
571    /// Diffusion loader type: "flux", "flux_offloaded"
572    diffusion_loader_type: Option<String>,
573    /// Speech loader type: "dia"
574    speech_loader_type: Option<String>,
575    /// Override auto-device-mapper max sequence length (default 4096).
576    /// Lowering this lets more layers fit on small GPUs, since the planner
577    /// reserves KV-cache headroom proportional to this value.
578    /// Honored by text, vision, and embedding pipelines.
579    max_seq_len: Option<usize>,
580    /// Override auto-device-mapper max batch size (default 1).
581    /// Honored by text, vision, and embedding pipelines.
582    max_batch_size: Option<usize>,
583    /// Override max image shape (height, width) for the multimodal planner
584    /// (default [1024, 1024]). Lowering this frees VRAM for layer placement
585    /// on small GPUs. Vision pipeline only.
586    max_image_shape: Option<[usize; 2]>,
587    /// Override max number of images per request (default 1).
588    /// Vision pipeline only.
589    max_num_images: Option<usize>,
590}
591
592// ---------------------------------------------------------------------------
593// ISQ type parsing
594// ---------------------------------------------------------------------------
595
596fn parse_isq_type(s: &str) -> Result<IsqType> {
597    match s.to_uppercase().as_str() {
598        "Q4_0" => Ok(IsqType::Q4_0),
599        "Q4_1" => Ok(IsqType::Q4_1),
600        "Q5_0" => Ok(IsqType::Q5_0),
601        "Q5_1" => Ok(IsqType::Q5_1),
602        "Q8_0" => Ok(IsqType::Q8_0),
603        "Q8_1" => Ok(IsqType::Q8_1),
604        "Q2K" => Ok(IsqType::Q2K),
605        "Q3K" => Ok(IsqType::Q3K),
606        "Q4K" => Ok(IsqType::Q4K),
607        "Q5K" => Ok(IsqType::Q5K),
608        "Q6K" => Ok(IsqType::Q6K),
609        "Q8K" => Ok(IsqType::Q8K),
610        "HQQ4" => Ok(IsqType::HQQ4),
611        "HQQ8" => Ok(IsqType::HQQ8),
612        "F8E4M3" => Ok(IsqType::F8E4M3),
613        "AFQ8" => Ok(IsqType::AFQ8),
614        "AFQ6" => Ok(IsqType::AFQ6),
615        "AFQ4" => Ok(IsqType::AFQ4),
616        "AFQ3" => Ok(IsqType::AFQ3),
617        "AFQ2" => Ok(IsqType::AFQ2),
618        other => Err(RuntimeError::Config(format!(
619            "Unknown ISQ type '{}'. Valid types: Q4_0, Q4_1, Q5_0, Q5_1, Q8_0, Q8_1, \
620             Q2K, Q3K, Q4K, Q5K, Q6K, Q8K, HQQ4, HQQ8, F8E4M3, AFQ2-AFQ8",
621            other
622        ))),
623    }
624}
625
626// ---------------------------------------------------------------------------
627// Model dtype parsing
628// ---------------------------------------------------------------------------
629
630fn parse_model_dtype(s: &str) -> Result<ModelDType> {
631    match s.to_lowercase().as_str() {
632        "auto" => Ok(ModelDType::Auto),
633        "f16" => Ok(ModelDType::F16),
634        "bf16" => Ok(ModelDType::BF16),
635        "f32" => Ok(ModelDType::F32),
636        other => Err(RuntimeError::Config(format!(
637            "Unknown dtype '{}'. Valid values: auto, f16, bf16, f32",
638            other
639        ))),
640    }
641}
642
643fn resolve_model_dtype(opts: &MistralRsOptions) -> Result<ModelDType> {
644    if let Some(ref s) = opts.dtype {
645        return parse_model_dtype(s);
646    }
647    if opts.force_cpu {
648        tracing::info!("force_cpu=true; defaulting dtype to F32");
649        Ok(ModelDType::F32)
650    } else if !has_gpu_support() {
651        tracing::info!("GPU feature not enabled (gpu-cuda/gpu-metal); defaulting dtype to F32");
652        Ok(ModelDType::F32)
653    } else {
654        Ok(ModelDType::Auto)
655    }
656}
657
658#[allow(unexpected_cfgs)]
659fn has_gpu_support() -> bool {
660    cfg!(any(feature = "gpu-cuda", feature = "gpu-metal"))
661}
662
663/// Build a text-style `DeviceMapSetting` if any text-relevant override is set.
664///
665/// Returns `None` when the user has not opted in, so callers leave the
666/// builder at its default (mistralrs picks `default_text` internally).
667fn build_device_map_setting_text(opts: &MistralRsOptions) -> Option<DeviceMapSetting> {
668    if opts.max_seq_len.is_none() && opts.max_batch_size.is_none() {
669        return None;
670    }
671    Some(DeviceMapSetting::Auto(AutoDeviceMapParams::Text {
672        max_seq_len: opts
673            .max_seq_len
674            .unwrap_or(AutoDeviceMapParams::DEFAULT_MAX_SEQ_LEN),
675        max_batch_size: opts
676            .max_batch_size
677            .unwrap_or(AutoDeviceMapParams::DEFAULT_MAX_BATCH_SIZE),
678    }))
679}
680
681/// Build a multimodal `DeviceMapSetting` if any text- or multimodal-relevant
682/// override is set.
683fn build_device_map_setting_multimodal(opts: &MistralRsOptions) -> Option<DeviceMapSetting> {
684    if opts.max_seq_len.is_none()
685        && opts.max_batch_size.is_none()
686        && opts.max_image_shape.is_none()
687        && opts.max_num_images.is_none()
688    {
689        return None;
690    }
691    let max_image_shape = opts.max_image_shape.map(|[h, w]| (h, w)).unwrap_or((
692        AutoDeviceMapParams::DEFAULT_MAX_IMAGE_LENGTH,
693        AutoDeviceMapParams::DEFAULT_MAX_IMAGE_LENGTH,
694    ));
695    Some(DeviceMapSetting::Auto(AutoDeviceMapParams::Multimodal {
696        max_seq_len: opts
697            .max_seq_len
698            .unwrap_or(AutoDeviceMapParams::DEFAULT_MAX_SEQ_LEN),
699        max_batch_size: opts
700            .max_batch_size
701            .unwrap_or(AutoDeviceMapParams::DEFAULT_MAX_BATCH_SIZE),
702        max_image_shape,
703        max_num_images: opts
704            .max_num_images
705            .unwrap_or(AutoDeviceMapParams::DEFAULT_MAX_NUM_IMAGES),
706    }))
707}
708
709/// Extract the text of the last user message, which is the most relevant
710/// prompt for single-shot pipelines like diffusion and speech.
711fn extract_last_user_prompt(messages: &[Message]) -> String {
712    messages
713        .iter()
714        .rev()
715        .filter(|m| m.role == MessageRole::User)
716        .flat_map(|m| m.content.iter())
717        .find_map(|b| match b {
718            ContentBlock::Text(t) => Some(t.clone()),
719            _ => None,
720        })
721        .unwrap_or_default()
722}
723
724// ---------------------------------------------------------------------------
725// Embedding validation
726// ---------------------------------------------------------------------------
727
728fn validate_embeddings(embeddings: &[Vec<f32>]) -> Result<()> {
729    for (i, vec) in embeddings.iter().enumerate() {
730        let nan_count = vec.iter().filter(|v| v.is_nan()).count();
731        let inf_count = vec.iter().filter(|v| v.is_infinite()).count();
732        if nan_count > 0 || inf_count > 0 {
733            return Err(RuntimeError::InferenceError(format!(
734                "Embedding vector {} contains invalid values ({} NaN, {} Inf out of {} dims). \
735                 This typically happens with F16 on CPU. Set options: {{\"dtype\": \"f32\"}}.",
736                i,
737                nan_count,
738                inf_count,
739                vec.len()
740            )));
741        }
742    }
743    Ok(())
744}
745
746// ---------------------------------------------------------------------------
747// Embedding service
748// ---------------------------------------------------------------------------
749
750struct MistralRsEmbeddingService {
751    model: Model,
752    model_id: String,
753    dimensions: u32,
754}
755
756#[async_trait]
757impl EmbeddingModel for MistralRsEmbeddingService {
758    async fn embed(&self, texts: &[&str]) -> Result<EmbedResult> {
759        if texts.is_empty() {
760            return Ok(EmbedResult {
761                vectors: vec![],
762                usage: None,
763            });
764        }
765
766        let request =
767            EmbeddingRequestBuilder::new().add_prompts(texts.iter().map(|s| s.to_string()));
768
769        let embeddings = self.model.generate_embeddings(request).await.map_err(|e| {
770            RuntimeError::InferenceError(format!("Embedding inference failed: {}", e))
771        })?;
772
773        validate_embeddings(&embeddings)?;
774
775        Ok(EmbedResult {
776            vectors: embeddings,
777            usage: None,
778        })
779    }
780
781    fn dimensions(&self) -> u32 {
782        self.dimensions
783    }
784}
785
786impl ModelInfo for MistralRsEmbeddingService {
787    fn model_id(&self) -> &str {
788        &self.model_id
789    }
790}
791
792// ---------------------------------------------------------------------------
793// Generator service
794// ---------------------------------------------------------------------------
795
796struct MistralRsGeneratorService {
797    model: Model,
798    model_id: String,
799}
800
801impl ModelInfo for MistralRsGeneratorService {
802    fn model_id(&self) -> &str {
803        &self.model_id
804    }
805}
806
807#[async_trait]
808impl GeneratorModel for MistralRsGeneratorService {
809    async fn generate(
810        &self,
811        messages: &[Message],
812        options: GenerationOptions,
813    ) -> Result<GenerationResult> {
814        let mut request = RequestBuilder::new();
815
816        for msg in messages {
817            let role = match msg.role {
818                MessageRole::System => TextMessageRole::System,
819                MessageRole::User => TextMessageRole::User,
820                MessageRole::Assistant => TextMessageRole::Assistant,
821            };
822            request = request.add_message(role, msg.text());
823        }
824
825        // Apply sampling parameters
826        let has_sampling = options.temperature.is_some()
827            || options.top_p.is_some()
828            || options.max_tokens.is_some();
829
830        if has_sampling {
831            if let Some(temp) = options.temperature {
832                request = request.set_sampler_temperature(temp as f64);
833            }
834            if let Some(top_p) = options.top_p {
835                request = request.set_sampler_topp(top_p as f64);
836            }
837            if let Some(max_tokens) = options.max_tokens {
838                request = request.set_sampler_max_len(max_tokens);
839            }
840        } else {
841            request = request.set_deterministic_sampler();
842        }
843
844        let response = self.model.send_chat_request(request).await.map_err(|e| {
845            RuntimeError::InferenceError(format!("Generation inference failed: {}", e))
846        })?;
847
848        let text = response
849            .choices
850            .first()
851            .and_then(|c| c.message.content.as_deref())
852            .unwrap_or("")
853            .to_string();
854
855        let usage = TokenUsage {
856            prompt_tokens: response.usage.prompt_tokens,
857            completion_tokens: response.usage.completion_tokens,
858            total_tokens: response.usage.total_tokens,
859        };
860
861        Ok(GenerationResult {
862            text,
863            usage: Some(usage),
864            images: vec![],
865            audio: None,
866        })
867    }
868}
869
870// ---------------------------------------------------------------------------
871// Vision service
872// ---------------------------------------------------------------------------
873
874struct MistralRsVisionService {
875    model: Model,
876    model_id: String,
877}
878
879impl ModelInfo for MistralRsVisionService {
880    fn model_id(&self) -> &str {
881        &self.model_id
882    }
883}
884
885#[async_trait]
886impl GeneratorModel for MistralRsVisionService {
887    async fn generate(
888        &self,
889        messages: &[Message],
890        options: GenerationOptions,
891    ) -> Result<GenerationResult> {
892        let mut request = RequestBuilder::new();
893
894        for msg in messages {
895            let role = match msg.role {
896                MessageRole::System => TextMessageRole::System,
897                MessageRole::User => TextMessageRole::User,
898                MessageRole::Assistant => TextMessageRole::Assistant,
899            };
900
901            // Collect images from this message
902            let mut images: Vec<image::DynamicImage> = Vec::new();
903            for block in &msg.content {
904                if let ContentBlock::Image(img_input) = block {
905                    let bytes = match img_input {
906                        crate::traits::ImageInput::Bytes { data, .. } => data.clone(),
907                        crate::traits::ImageInput::Url(_url) => {
908                            return Err(RuntimeError::Config(
909                                "URL-based image input not yet supported in vision pipeline"
910                                    .to_string(),
911                            ));
912                        }
913                    };
914                    let img = image::load_from_memory(&bytes).map_err(|e| {
915                        RuntimeError::InferenceError(format!("Failed to decode image: {}", e))
916                    })?;
917                    images.push(img);
918                }
919            }
920
921            let text = msg.text();
922
923            if images.is_empty() {
924                request = request.add_message(role, text);
925            } else {
926                request = request.add_image_message(role, text, images);
927            }
928        }
929
930        // Apply sampling parameters
931        let has_sampling = options.temperature.is_some()
932            || options.top_p.is_some()
933            || options.max_tokens.is_some();
934
935        if has_sampling {
936            if let Some(temp) = options.temperature {
937                request = request.set_sampler_temperature(temp as f64);
938            }
939            if let Some(top_p) = options.top_p {
940                request = request.set_sampler_topp(top_p as f64);
941            }
942            if let Some(max_tokens) = options.max_tokens {
943                request = request.set_sampler_max_len(max_tokens);
944            }
945        } else {
946            request = request.set_deterministic_sampler();
947        }
948
949        let response =
950            self.model.send_chat_request(request).await.map_err(|e| {
951                RuntimeError::InferenceError(format!("Vision inference failed: {}", e))
952            })?;
953
954        let text = response
955            .choices
956            .first()
957            .and_then(|c| c.message.content.as_deref())
958            .unwrap_or("")
959            .to_string();
960
961        let usage = TokenUsage {
962            prompt_tokens: response.usage.prompt_tokens,
963            completion_tokens: response.usage.completion_tokens,
964            total_tokens: response.usage.total_tokens,
965        };
966
967        Ok(GenerationResult {
968            text,
969            usage: Some(usage),
970            images: vec![],
971            audio: None,
972        })
973    }
974}
975
976// ---------------------------------------------------------------------------
977// Document extraction service (olmOCR-2 and similar, on the vision pipeline)
978// ---------------------------------------------------------------------------
979
980/// The olmOCR-2 prompt — image-only, no document-anchoring text.
981///
982/// olmOCR-2 dropped olmOCR-1's anchor-text requirement; this is the static
983/// no-anchoring instruction. The model is given a single rendered page and
984/// returns Markdown with a YAML front-matter header.
985const OLMOCR_PROMPT: &str = "Attached is one page of a document that you must process. \
986Just return the plain text representation of this document as if you were reading it naturally. \
987Convert equations to LateX and tables to HTML. \
988If there are any figures or charts, label them with the following markdown syntax \
989`![Alt text...](page_startx_starty_width_height.png)`. \
990Return your output as markdown, with a front matter section on top specifying values for the \
991primary_language, is_rotation_valid, rotation_correction, is_table, and is_diagram parameters.";
992
993/// First-pass sampling temperature for document extraction.
994///
995/// olmOCR's reference pipeline starts near-greedy and only escalates on retry;
996/// this is the deterministic-leaning first pass. Temperature-escalating retries
997/// are a future refinement layered above this single pass.
998const DOC_EXTRACT_TEMPERATURE: f32 = 0.1;
999
1000/// Maximum tokens to generate per page (olmOCR's reference cap).
1001const DOC_EXTRACT_MAX_TOKENS: usize = 8000;
1002
1003/// Document extractor wrapping a mistral.rs vision generator (e.g. olmOCR-2).
1004struct MistralRsDocumentExtractor {
1005    generator: Arc<dyn GeneratorModel>,
1006    model_id: String,
1007    style: crate::doc_parse::DocStyle,
1008}
1009
1010impl ModelInfo for MistralRsDocumentExtractor {
1011    fn model_id(&self) -> &str {
1012        &self.model_id
1013    }
1014}
1015
1016#[async_trait]
1017impl DocumentExtractionModel for MistralRsDocumentExtractor {
1018    async fn extract(
1019        &self,
1020        pages: Vec<ImageInput>,
1021        _options: DocExtractOptions,
1022    ) -> Result<Vec<DocExtractResult>> {
1023        let mut results = Vec::with_capacity(pages.len());
1024        // One page per request: olmOCR-2 is single-image, and this sidesteps the
1025        // known multi-image KV-cache issue in the vision pipeline.
1026        for page in pages {
1027            let messages = [Message {
1028                role: MessageRole::User,
1029                content: vec![
1030                    ContentBlock::Text(OLMOCR_PROMPT.to_string()),
1031                    ContentBlock::Image(page),
1032                ],
1033            }];
1034            let options = GenerationOptions {
1035                max_tokens: Some(DOC_EXTRACT_MAX_TOKENS),
1036                temperature: Some(DOC_EXTRACT_TEMPERATURE),
1037                top_p: None,
1038                width: None,
1039                height: None,
1040            };
1041            let generated = self.generator.generate(&messages, options).await?;
1042            results.push(crate::doc_parse::parse_by_style(
1043                self.style,
1044                &generated.text,
1045            ));
1046        }
1047        Ok(results)
1048    }
1049}
1050
1051// ---------------------------------------------------------------------------
1052// Diffusion service
1053// ---------------------------------------------------------------------------
1054
1055struct MistralRsDiffusionService {
1056    model: Model,
1057    model_id: String,
1058}
1059
1060impl ModelInfo for MistralRsDiffusionService {
1061    fn model_id(&self) -> &str {
1062        &self.model_id
1063    }
1064}
1065
1066#[async_trait]
1067impl GeneratorModel for MistralRsDiffusionService {
1068    async fn generate(
1069        &self,
1070        messages: &[Message],
1071        options: GenerationOptions,
1072    ) -> Result<GenerationResult> {
1073        use mistralrs::DiffusionGenerationParams;
1074
1075        // Extract the text prompt from the last user message
1076        let prompt = extract_last_user_prompt(messages);
1077
1078        let height = options.height.unwrap_or(720) as usize;
1079        let width = options.width.unwrap_or(1280) as usize;
1080
1081        let response = self
1082            .model
1083            .generate_image(
1084                prompt,
1085                mistralrs::ImageGenerationResponseFormat::B64Json,
1086                DiffusionGenerationParams { height, width },
1087                None,
1088            )
1089            .await
1090            .map_err(|e| {
1091                RuntimeError::InferenceError(format!("Diffusion inference failed: {}", e))
1092            })?;
1093
1094        // The response is a base64-encoded image
1095        let first = response.data.first().ok_or_else(|| {
1096            RuntimeError::InferenceError("Diffusion response returned no image data".to_string())
1097        })?;
1098        let b64 = first.b64_json.as_deref().ok_or_else(|| {
1099            RuntimeError::InferenceError("Diffusion response missing b64_json data".to_string())
1100        })?;
1101        let image_data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
1102            .map_err(|e| {
1103                RuntimeError::InferenceError(format!("Failed to decode diffusion output: {}", e))
1104            })?;
1105
1106        Ok(GenerationResult {
1107            text: String::new(),
1108            usage: None,
1109            images: vec![crate::traits::GeneratedImage {
1110                data: image_data,
1111                media_type: "image/png".to_string(),
1112            }],
1113            audio: None,
1114        })
1115    }
1116}
1117
1118// ---------------------------------------------------------------------------
1119// Speech service
1120// ---------------------------------------------------------------------------
1121
1122struct MistralRsSpeechService {
1123    model: Model,
1124    model_id: String,
1125}
1126
1127impl ModelInfo for MistralRsSpeechService {
1128    fn model_id(&self) -> &str {
1129        &self.model_id
1130    }
1131}
1132
1133#[async_trait]
1134impl GeneratorModel for MistralRsSpeechService {
1135    async fn generate(
1136        &self,
1137        messages: &[Message],
1138        _options: GenerationOptions,
1139    ) -> Result<GenerationResult> {
1140        // Extract the text prompt from the last user message
1141        let prompt = extract_last_user_prompt(messages);
1142
1143        let (pcm_data, sample_rate, channels) =
1144            self.model.generate_speech(prompt).await.map_err(|e| {
1145                RuntimeError::InferenceError(format!("Speech inference failed: {}", e))
1146            })?;
1147
1148        Ok(GenerationResult {
1149            text: String::new(),
1150            usage: None,
1151            images: vec![],
1152            audio: Some(crate::traits::AudioOutput {
1153                pcm_data: (*pcm_data).clone(),
1154                sample_rate,
1155                channels,
1156            }),
1157        })
1158    }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164
1165    // -----------------------------------------------------------------------
1166    // validate_embeddings
1167    // -----------------------------------------------------------------------
1168
1169    #[test]
1170    fn validate_embeddings_valid() {
1171        let vecs = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
1172        assert!(validate_embeddings(&vecs).is_ok());
1173    }
1174
1175    #[test]
1176    fn validate_embeddings_empty() {
1177        assert!(validate_embeddings(&[]).is_ok());
1178    }
1179
1180    #[test]
1181    fn validate_embeddings_nan() {
1182        let vecs = vec![vec![1.0, f32::NAN, 3.0]];
1183        let err = validate_embeddings(&vecs).unwrap_err();
1184        assert!(err.to_string().contains("NaN"));
1185    }
1186
1187    #[test]
1188    fn validate_embeddings_inf() {
1189        let vecs = vec![vec![1.0, f32::INFINITY, 3.0]];
1190        let err = validate_embeddings(&vecs).unwrap_err();
1191        assert!(err.to_string().contains("Inf"));
1192    }
1193
1194    #[test]
1195    fn validate_embeddings_all_nan() {
1196        let vecs = vec![vec![f32::NAN, f32::NAN, f32::NAN]];
1197        let err = validate_embeddings(&vecs).unwrap_err();
1198        assert!(err.to_string().contains("3 NaN"));
1199    }
1200
1201    // -----------------------------------------------------------------------
1202    // parse_model_dtype
1203    // -----------------------------------------------------------------------
1204
1205    #[test]
1206    fn parse_model_dtype_valid() {
1207        assert!(matches!(parse_model_dtype("auto"), Ok(ModelDType::Auto)));
1208        assert!(matches!(parse_model_dtype("f16"), Ok(ModelDType::F16)));
1209        assert!(matches!(parse_model_dtype("bf16"), Ok(ModelDType::BF16)));
1210        assert!(matches!(parse_model_dtype("f32"), Ok(ModelDType::F32)));
1211    }
1212
1213    #[test]
1214    fn parse_model_dtype_case_insensitive() {
1215        assert!(matches!(parse_model_dtype("F16"), Ok(ModelDType::F16)));
1216        assert!(matches!(parse_model_dtype("BF16"), Ok(ModelDType::BF16)));
1217        assert!(matches!(parse_model_dtype("Auto"), Ok(ModelDType::Auto)));
1218    }
1219
1220    #[test]
1221    fn parse_model_dtype_invalid() {
1222        let err = parse_model_dtype("int8").unwrap_err();
1223        assert!(err.to_string().contains("Unknown dtype"));
1224    }
1225
1226    // -----------------------------------------------------------------------
1227    // resolve_model_dtype
1228    // -----------------------------------------------------------------------
1229
1230    #[test]
1231    fn resolve_model_dtype_explicit_overrides_force_cpu() {
1232        let opts = MistralRsOptions {
1233            dtype: Some("f16".to_string()),
1234            force_cpu: true,
1235            ..Default::default()
1236        };
1237        assert!(matches!(resolve_model_dtype(&opts), Ok(ModelDType::F16)));
1238    }
1239
1240    #[test]
1241    fn resolve_model_dtype_force_cpu_defaults_f32() {
1242        let opts = MistralRsOptions {
1243            force_cpu: true,
1244            ..Default::default()
1245        };
1246        assert!(matches!(resolve_model_dtype(&opts), Ok(ModelDType::F32)));
1247    }
1248
1249    #[test]
1250    fn resolve_model_dtype_no_gpu_defaults_f32() {
1251        // Without gpu-cuda or gpu-metal features, has_gpu_support() returns false.
1252        let opts = MistralRsOptions::default();
1253        if !has_gpu_support() {
1254            assert!(matches!(resolve_model_dtype(&opts), Ok(ModelDType::F32)));
1255        }
1256    }
1257
1258    mod extract_last_user_prompt_tests {
1259        use super::*;
1260
1261        #[test]
1262        fn returns_last_user_text() {
1263            let messages = vec![
1264                Message::user("first"),
1265                Message::assistant("reply"),
1266                Message::user("second"),
1267            ];
1268            assert_eq!(extract_last_user_prompt(&messages), "second");
1269        }
1270
1271        #[test]
1272        fn skips_system_and_assistant() {
1273            let messages = vec![
1274                Message::system("system prompt"),
1275                Message::assistant("assistant reply"),
1276            ];
1277            assert_eq!(extract_last_user_prompt(&messages), "");
1278        }
1279
1280        #[test]
1281        fn empty_messages_returns_empty() {
1282            assert_eq!(extract_last_user_prompt(&[]), "");
1283        }
1284    }
1285}
1286
1287#[cfg(test)]
1288mod doc_extract_tests {
1289    use super::*;
1290    use crate::traits::{DocBlockKind, DocOutputFormat};
1291
1292    /// A vision generator stub that returns scripted text and asserts the
1293    /// per-request contract (one message carrying the olmOCR prompt + exactly
1294    /// one page image).
1295    struct MockVisionGen {
1296        reply: String,
1297    }
1298
1299    impl ModelInfo for MockVisionGen {
1300        fn model_id(&self) -> &str {
1301            "mock/vision"
1302        }
1303    }
1304
1305    #[async_trait]
1306    impl GeneratorModel for MockVisionGen {
1307        async fn generate(
1308            &self,
1309            messages: &[Message],
1310            _options: GenerationOptions,
1311        ) -> Result<GenerationResult> {
1312            assert_eq!(messages.len(), 1, "one message per request");
1313            let image_count = messages[0]
1314                .content
1315                .iter()
1316                .filter(|b| matches!(b, ContentBlock::Image(_)))
1317                .count();
1318            assert_eq!(image_count, 1, "exactly one page image per request");
1319            let has_prompt = messages[0]
1320                .content
1321                .iter()
1322                .any(|b| matches!(b, ContentBlock::Text(t) if t.contains("front matter")));
1323            assert!(has_prompt, "the olmOCR prompt must be included");
1324            Ok(GenerationResult {
1325                text: self.reply.clone(),
1326                usage: None,
1327                images: vec![],
1328                audio: None,
1329            })
1330        }
1331    }
1332
1333    fn page() -> ImageInput {
1334        ImageInput::Bytes {
1335            data: vec![0u8; 4],
1336            media_type: "image/png".to_string(),
1337        }
1338    }
1339
1340    fn options() -> DocExtractOptions {
1341        DocExtractOptions {
1342            output: DocOutputFormat::Markdown,
1343            include_tables: true,
1344            include_formulas: true,
1345            include_bboxes: true,
1346        }
1347    }
1348
1349    #[tokio::test]
1350    async fn olmocr_extractor_builds_prompt_and_parses_output() {
1351        let generator: Arc<dyn GeneratorModel> = Arc::new(MockVisionGen {
1352            reply: "# Title\n\nA paragraph.".to_string(),
1353        });
1354        let extractor = MistralRsDocumentExtractor {
1355            generator,
1356            model_id: "allenai/olmOCR-2-7B-1025".to_string(),
1357            style: crate::doc_parse::DocStyle::OlmOcr,
1358        };
1359
1360        let results = extractor.extract(vec![page()], options()).await.unwrap();
1361        assert_eq!(results.len(), 1);
1362        assert_eq!(results[0].blocks[0].kind, DocBlockKind::Heading);
1363        assert_eq!(results[0].blocks[0].content, "Title");
1364    }
1365
1366    #[tokio::test]
1367    async fn olmocr_extractor_processes_each_page_singly() {
1368        let generator: Arc<dyn GeneratorModel> = Arc::new(MockVisionGen {
1369            reply: "body".to_string(),
1370        });
1371        let extractor = MistralRsDocumentExtractor {
1372            generator,
1373            model_id: "olmocr".to_string(),
1374            style: crate::doc_parse::DocStyle::OlmOcr,
1375        };
1376
1377        // Two pages -> two results; the per-request assertions guarantee each
1378        // generate() call carried exactly one image.
1379        let results = extractor
1380            .extract(vec![page(), page()], options())
1381            .await
1382            .unwrap();
1383        assert_eq!(results.len(), 2);
1384    }
1385}