Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/realizar/src/bench/runtime.rs
Line
Count
Source
1
//! Runtime backend abstraction for benchmark comparison
2
//!
3
//! Extracted from bench/mod.rs (PMAT-802) to reduce module size.
4
//! Contains:
5
//! - BENCH-002: Runtime Backend Abstraction
6
//! - LlamaCppBackend, VllmBackend, OllamaBackend implementations
7
8
#![allow(clippy::cast_precision_loss)]
9
10
use std::collections::HashMap;
11
12
use serde::{Deserialize, Serialize};
13
14
use crate::error::RealizarError;
15
16
#[cfg(feature = "bench-http")]
17
use crate::http_client::{CompletionRequest, ModelHttpClient, OllamaOptions, OllamaRequest};
18
19
/// Supported runtime types for inference benchmarking
20
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21
pub enum RuntimeType {
22
    /// Native Realizar runtime (.apr format)
23
    Realizar,
24
    /// llama.cpp (GGUF format)
25
    LlamaCpp,
26
    /// vLLM (safetensors, HuggingFace)
27
    Vllm,
28
    /// Ollama (wraps llama.cpp)
29
    Ollama,
30
}
31
32
impl RuntimeType {
33
    /// Get string representation
34
    #[must_use]
35
12
    pub fn as_str(&self) -> &'static str {
36
12
        match self {
37
4
            Self::Realizar => "realizar",
38
3
            Self::LlamaCpp => "llama-cpp",
39
2
            Self::Vllm => "vllm",
40
3
            Self::Ollama => "ollama",
41
        }
42
12
    }
43
44
    /// Parse from string
45
    #[must_use]
46
6
    pub fn parse(s: &str) -> Option<Self> {
47
6
        match s.to_lowercase().as_str() {
48
6
            "realizar" => 
Some(Self::Realizar)1
,
49
5
            "llama-cpp" | 
"llama.cpp"4
|
"llamacpp"3
=>
Some(Self::LlamaCpp)2
,
50
3
            "vllm" => 
Some(Self::Vllm)1
,
51
2
            "ollama" => 
Some(Self::Ollama)1
,
52
1
            _ => None,
53
        }
54
6
    }
55
}
56
57
/// Request for inference
58
#[derive(Debug, Clone, Serialize, Deserialize)]
59
pub struct InferenceRequest {
60
    /// Input prompt
61
    pub prompt: String,
62
    /// Maximum tokens to generate
63
    pub max_tokens: usize,
64
    /// Sampling temperature
65
    pub temperature: f64,
66
    /// Optional stop sequences
67
    pub stop: Vec<String>,
68
}
69
70
impl Default for InferenceRequest {
71
0
    fn default() -> Self {
72
0
        Self {
73
0
            prompt: String::new(),
74
0
            max_tokens: 100,
75
0
            temperature: 0.7,
76
0
            stop: Vec::new(),
77
0
        }
78
0
    }
79
}
80
81
impl InferenceRequest {
82
    /// Create new request with prompt
83
    #[must_use]
84
0
    pub fn new(prompt: &str) -> Self {
85
0
        Self {
86
0
            prompt: prompt.to_string(),
87
0
            ..Default::default()
88
0
        }
89
0
    }
90
91
    /// Set max tokens
92
    #[must_use]
93
0
    pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
94
0
        self.max_tokens = max_tokens;
95
0
        self
96
0
    }
97
98
    /// Set temperature
99
    #[must_use]
100
0
    pub fn with_temperature(mut self, temperature: f64) -> Self {
101
0
        self.temperature = temperature;
102
0
        self
103
0
    }
104
}
105
106
/// Response from inference
107
#[derive(Debug, Clone, Serialize, Deserialize)]
108
pub struct InferenceResponse {
109
    /// Generated text
110
    pub text: String,
111
    /// Number of tokens generated
112
    pub tokens_generated: usize,
113
    /// Time to first token (ms)
114
    pub ttft_ms: f64,
115
    /// Total generation time (ms)
116
    pub total_time_ms: f64,
117
    /// Inter-token latencies (ms)
118
    pub itl_ms: Vec<f64>,
119
}
120
121
impl InferenceResponse {
122
    /// Calculate tokens per second
123
    #[must_use]
124
0
    pub fn tokens_per_second(&self) -> f64 {
125
0
        if self.total_time_ms <= 0.0 {
126
0
            return 0.0;
127
0
        }
128
0
        (self.tokens_generated as f64) / (self.total_time_ms / 1000.0)
129
0
    }
130
}
131
132
/// Runtime backend information
133
#[derive(Debug, Clone, Serialize, Deserialize)]
134
pub struct BackendInfo {
135
    /// Runtime type
136
    pub runtime_type: RuntimeType,
137
    /// Version string
138
    pub version: String,
139
    /// Whether streaming is supported
140
    pub supports_streaming: bool,
141
    /// Model currently loaded (if any)
142
    pub loaded_model: Option<String>,
143
}
144
145
/// Trait for inference runtime backends
146
pub trait RuntimeBackend: Send + Sync {
147
    /// Get backend information
148
    fn info(&self) -> BackendInfo;
149
150
    /// Run inference
151
    ///
152
    /// # Errors
153
    ///
154
    /// Returns `RealizarError` if inference fails due to:
155
    /// - Model not loaded
156
    /// - Backend communication failure
157
    /// - Invalid request parameters
158
    fn inference(&self, request: &InferenceRequest) -> Result<InferenceResponse, RealizarError>;
159
160
    /// Load a model (if applicable)
161
    ///
162
    /// # Errors
163
    ///
164
    /// Returns `RealizarError` if model loading fails due to:
165
    /// - Model file not found
166
    /// - Invalid model format
167
    /// - Insufficient memory
168
0
    fn load_model(&mut self, _model_path: &str) -> Result<(), RealizarError> {
169
0
        Ok(()) // Default: no-op
170
0
    }
171
}
172
173
/// Mock backend for testing
174
pub struct MockBackend {
175
    ttft_ms: f64,
176
    tokens_per_second: f64,
177
}
178
179
impl MockBackend {
180
    /// Create a new mock backend with specified latencies
181
    #[must_use]
182
0
    pub fn new(ttft_ms: f64, tokens_per_second: f64) -> Self {
183
0
        Self {
184
0
            ttft_ms,
185
0
            tokens_per_second,
186
0
        }
187
0
    }
188
}
189
190
impl RuntimeBackend for MockBackend {
191
0
    fn info(&self) -> BackendInfo {
192
0
        BackendInfo {
193
0
            runtime_type: RuntimeType::Realizar,
194
0
            version: env!("CARGO_PKG_VERSION").to_string(),
195
0
            supports_streaming: true,
196
0
            loaded_model: None,
197
0
        }
198
0
    }
199
200
0
    fn inference(&self, request: &InferenceRequest) -> Result<InferenceResponse, RealizarError> {
201
0
        let tokens = request.max_tokens.min(100);
202
0
        let gen_time_ms = (tokens as f64) / self.tokens_per_second * 1000.0;
203
204
0
        Ok(InferenceResponse {
205
0
            text: "Mock response".to_string(),
206
0
            tokens_generated: tokens,
207
0
            ttft_ms: self.ttft_ms,
208
0
            total_time_ms: self.ttft_ms + gen_time_ms,
209
0
            itl_ms: vec![gen_time_ms / tokens as f64; tokens],
210
0
        })
211
0
    }
212
}
213
214
/// Registry of available backends
215
pub struct BackendRegistry {
216
    backends: HashMap<RuntimeType, Box<dyn RuntimeBackend>>,
217
}
218
219
impl BackendRegistry {
220
    /// Create empty registry
221
    #[must_use]
222
0
    pub fn new() -> Self {
223
0
        Self {
224
0
            backends: HashMap::new(),
225
0
        }
226
0
    }
227
228
    /// Register a backend
229
0
    pub fn register(&mut self, runtime: RuntimeType, backend: Box<dyn RuntimeBackend>) {
230
0
        self.backends.insert(runtime, backend);
231
0
    }
232
233
    /// Get a backend by type
234
    #[must_use]
235
0
    pub fn get(&self, runtime: RuntimeType) -> Option<&dyn RuntimeBackend> {
236
0
        self.backends.get(&runtime).map(AsRef::as_ref)
237
0
    }
238
239
    /// List registered runtimes
240
    #[must_use]
241
0
    pub fn list(&self) -> Vec<RuntimeType> {
242
0
        self.backends.keys().copied().collect()
243
0
    }
244
}
245
246
impl Default for BackendRegistry {
247
0
    fn default() -> Self {
248
0
        Self::new()
249
0
    }
250
}
251
252
/// Configuration for llama.cpp backend
253
#[derive(Debug, Clone, Serialize, Deserialize)]
254
pub struct LlamaCppConfig {
255
    /// Path to llama-cli binary
256
    pub binary_path: String,
257
    /// Path to model file
258
    pub model_path: Option<String>,
259
    /// Number of GPU layers to offload
260
    pub n_gpu_layers: u32,
261
    /// Context size
262
    pub ctx_size: usize,
263
    /// Number of threads
264
    pub threads: usize,
265
}
266
267
impl Default for LlamaCppConfig {
268
0
    fn default() -> Self {
269
0
        Self {
270
0
            binary_path: "llama-cli".to_string(),
271
0
            model_path: None,
272
0
            n_gpu_layers: 0,
273
0
            ctx_size: 2048,
274
0
            threads: 4,
275
0
        }
276
0
    }
277
}
278
279
impl LlamaCppConfig {
280
    /// Create new config with binary path
281
    #[must_use]
282
0
    pub fn new(binary_path: &str) -> Self {
283
0
        Self {
284
0
            binary_path: binary_path.to_string(),
285
0
            ..Default::default()
286
0
        }
287
0
    }
288
289
    /// Set model path
290
    #[must_use]
291
0
    pub fn with_model(mut self, model_path: &str) -> Self {
292
0
        self.model_path = Some(model_path.to_string());
293
0
        self
294
0
    }
295
296
    /// Set GPU layers
297
    #[must_use]
298
0
    pub fn with_gpu_layers(mut self, layers: u32) -> Self {
299
0
        self.n_gpu_layers = layers;
300
0
        self
301
0
    }
302
303
    /// Set context size
304
    #[must_use]
305
0
    pub fn with_ctx_size(mut self, ctx_size: usize) -> Self {
306
0
        self.ctx_size = ctx_size;
307
0
        self
308
0
    }
309
}
310
311
/// Configuration for vLLM backend
312
#[derive(Debug, Clone, Serialize, Deserialize)]
313
pub struct VllmConfig {
314
    /// Base URL for vLLM server
315
    pub base_url: String,
316
    /// API version
317
    pub api_version: String,
318
    /// Model name/path
319
    pub model: Option<String>,
320
    /// API key (if required)
321
    pub api_key: Option<String>,
322
}
323
324
impl Default for VllmConfig {
325
0
    fn default() -> Self {
326
0
        Self {
327
0
            base_url: "http://localhost:8000".to_string(),
328
0
            api_version: "v1".to_string(),
329
0
            model: None,
330
0
            api_key: None,
331
0
        }
332
0
    }
333
}
334
335
impl VllmConfig {
336
    /// Create new config with base URL
337
    #[must_use]
338
0
    pub fn new(base_url: &str) -> Self {
339
0
        Self {
340
0
            base_url: base_url.to_string(),
341
0
            ..Default::default()
342
0
        }
343
0
    }
344
345
    /// Set model
346
    #[must_use]
347
0
    pub fn with_model(mut self, model: &str) -> Self {
348
0
        self.model = Some(model.to_string());
349
0
        self
350
0
    }
351
352
    /// Set API key
353
    #[must_use]
354
0
    pub fn with_api_key(mut self, api_key: &str) -> Self {
355
0
        self.api_key = Some(api_key.to_string());
356
0
        self
357
0
    }
358
}
359
360
// ============================================================================
361
// LlamaCppBackend Implementation (BENCH-002)
362
// ============================================================================
363
364
/// llama.cpp backend for GGUF model inference via subprocess
365
pub struct LlamaCppBackend {
366
    config: LlamaCppConfig,
367
}
368
369
impl LlamaCppBackend {
370
    /// Create new llama.cpp backend
371
    #[must_use]
372
0
    pub fn new(config: LlamaCppConfig) -> Self {
373
0
        Self { config }
374
0
    }
375
376
    /// Build CLI arguments for llama-cli invocation
377
    #[must_use]
378
0
    pub fn build_cli_args(&self, request: &InferenceRequest) -> Vec<String> {
379
0
        let mut args = Vec::new();
380
381
        // Model path
382
0
        if let Some(ref model_path) = self.config.model_path {
383
0
            args.push("-m".to_string());
384
0
            args.push(model_path.clone());
385
0
        }
386
387
        // Prompt
388
0
        args.push("-p".to_string());
389
0
        args.push(request.prompt.clone());
390
391
        // Number of tokens to generate
392
0
        args.push("-n".to_string());
393
0
        args.push(request.max_tokens.to_string());
394
395
        // GPU layers
396
0
        args.push("-ngl".to_string());
397
0
        args.push(self.config.n_gpu_layers.to_string());
398
399
        // Context size
400
0
        args.push("-c".to_string());
401
0
        args.push(self.config.ctx_size.to_string());
402
403
        // Threads
404
0
        args.push("-t".to_string());
405
0
        args.push(self.config.threads.to_string());
406
407
        // Temperature (if non-default)
408
0
        if (request.temperature - 0.8).abs() > 0.01 {
409
0
            args.push("--temp".to_string());
410
0
            args.push(format!("{:.2}", request.temperature));
411
0
        }
412
413
0
        args
414
0
    }
415
416
    /// Parse a timing line from llama-cli output
417
    ///
418
    /// Example: `llama_perf_context_print: prompt eval time =      12.34 ms /    10 tokens`
419
    /// Returns: `Some((12.34, 10))`
420
    #[must_use]
421
0
    pub fn parse_timing_line(output: &str, metric_name: &str) -> Option<(f64, usize)> {
422
0
        for line in output.lines() {
423
            // For "eval time", we need to exclude "prompt eval time"
424
0
            let matches = if metric_name == "eval time" {
425
0
                line.contains(metric_name) && !line.contains("prompt eval time")
426
            } else {
427
0
                line.contains(metric_name)
428
            };
429
430
0
            if matches && line.contains('=') {
431
                // Extract the value after "=" and before "ms"
432
                // Format: "metric_name =      12.34 ms /    10 tokens"
433
0
                if let Some(eq_pos) = line.find('=') {
434
0
                    let after_eq = &line[eq_pos + 1..];
435
                    // Find ms position
436
0
                    if let Some(ms_pos) = after_eq.find("ms") {
437
0
                        let value_str = after_eq[..ms_pos].trim();
438
0
                        if let Ok(value) = value_str.parse::<f64>() {
439
                            // Find the count after "/"
440
0
                            if let Some(slash_pos) = after_eq.find('/') {
441
0
                                let after_slash = &after_eq[slash_pos + 1..];
442
                                // Extract number before "tokens" or "runs"
443
0
                                let count_str =
444
0
                                    after_slash.split_whitespace().next().unwrap_or("0");
445
0
                                if let Ok(count) = count_str.parse::<usize>() {
446
0
                                    return Some((value, count));
447
0
                                }
448
0
                            }
449
0
                        }
450
0
                    }
451
0
                }
452
0
            }
453
        }
454
0
        None
455
0
    }
456
457
    /// Extract generated text from llama-cli output (before timing lines)
458
    #[must_use]
459
0
    pub fn extract_generated_text(output: &str) -> String {
460
0
        let mut text_lines = Vec::new();
461
0
        for line in output.lines() {
462
            // Stop when we hit timing/performance lines
463
0
            if line.contains("llama_perf_") || line.contains("sampler") {
464
0
                break;
465
0
            }
466
0
            text_lines.push(line);
467
        }
468
0
        text_lines.join("\n").trim().to_string()
469
0
    }
470
471
    /// Parse full CLI output into InferenceResponse
472
    ///
473
    /// # Errors
474
    ///
475
    /// Returns error if timing information cannot be parsed from output.
476
0
    pub fn parse_cli_output(output: &str) -> Result<InferenceResponse, RealizarError> {
477
        // Extract generated text
478
0
        let text = Self::extract_generated_text(output);
479
480
        // Parse timing metrics
481
0
        let ttft_ms = Self::parse_timing_line(output, "prompt eval time").map_or(0.0, |(ms, _)| ms);
482
483
0
        let (total_time_ms, _) = Self::parse_timing_line(output, "total time").unwrap_or((0.0, 0));
484
485
0
        let (_, tokens_generated) =
486
0
            Self::parse_timing_line(output, "eval time").unwrap_or((0.0, 0));
487
488
        // ITL is not directly available from CLI output, estimate from eval time
489
0
        let eval_time = Self::parse_timing_line(output, "eval time").map_or(0.0, |(ms, _)| ms);
490
491
0
        let itl_ms = if tokens_generated > 1 {
492
0
            let avg_itl = eval_time / (tokens_generated as f64);
493
0
            vec![avg_itl; tokens_generated.saturating_sub(1)]
494
        } else {
495
0
            vec![]
496
        };
497
498
0
        Ok(InferenceResponse {
499
0
            text,
500
0
            tokens_generated,
501
0
            ttft_ms,
502
0
            total_time_ms,
503
0
            itl_ms,
504
0
        })
505
0
    }
506
}
507
508
impl RuntimeBackend for LlamaCppBackend {
509
0
    fn info(&self) -> BackendInfo {
510
0
        BackendInfo {
511
0
            runtime_type: RuntimeType::LlamaCpp,
512
0
            version: "b2345".to_string(), // Would be detected from binary
513
0
            supports_streaming: false,    // CLI mode doesn't stream
514
0
            loaded_model: self.config.model_path.clone(),
515
0
        }
516
0
    }
517
518
0
    fn inference(&self, request: &InferenceRequest) -> Result<InferenceResponse, RealizarError> {
519
        use std::process::Command;
520
521
        // Require model path
522
0
        let model_path = self.config.model_path.as_ref().ok_or_else(|| {
523
0
            RealizarError::InvalidConfiguration("model_path is required".to_string())
524
0
        })?;
525
526
        // Build CLI arguments
527
0
        let args = self.build_cli_args(request);
528
529
        // Execute llama-cli
530
0
        let output = Command::new(&self.config.binary_path)
531
0
            .args(&args)
532
0
            .output()
533
0
            .map_err(|e| {
534
0
                RealizarError::ModelNotFound(format!(
535
0
                    "Failed to execute {}: {}",
536
0
                    self.config.binary_path, e
537
0
                ))
538
0
            })?;
539
540
0
        if !output.status.success() {
541
0
            let stderr = String::from_utf8_lossy(&output.stderr);
542
0
            return Err(RealizarError::InferenceError(format!(
543
0
                "llama-cli failed: {} (model: {})",
544
0
                stderr, model_path
545
0
            )));
546
0
        }
547
548
        // Parse stdout for response and timing
549
0
        let stdout = String::from_utf8_lossy(&output.stdout);
550
0
        let stderr = String::from_utf8_lossy(&output.stderr);
551
552
        // Timing info is often in stderr, combine both
553
0
        let combined_output = format!("{}\n{}", stdout, stderr);
554
0
        Self::parse_cli_output(&combined_output)
555
0
    }
556
}
557
558
// ============================================================================
559
// VllmBackend Implementation (BENCH-003) - REAL HTTP CALLS
560
// ============================================================================
561
562
/// vLLM backend for inference via HTTP API
563
///
564
/// **REAL IMPLEMENTATION** - makes actual HTTP requests to vLLM servers.
565
/// No mock data. Measures real latency and throughput.
566
#[cfg(feature = "bench-http")]
567
pub struct VllmBackend {
568
    config: VllmConfig,
569
    http_client: ModelHttpClient,
570
}
571
572
#[cfg(feature = "bench-http")]
573
impl VllmBackend {
574
    /// Create new vLLM backend with default HTTP client
575
    #[must_use]
576
    pub fn new(config: VllmConfig) -> Self {
577
        Self {
578
            config,
579
            http_client: ModelHttpClient::new(),
580
        }
581
    }
582
583
    /// Create new vLLM backend with custom HTTP client
584
    #[must_use]
585
    pub fn with_client(config: VllmConfig, client: ModelHttpClient) -> Self {
586
        Self {
587
            config,
588
            http_client: client,
589
        }
590
    }
591
}
592
593
#[cfg(feature = "bench-http")]
594
impl RuntimeBackend for VllmBackend {
595
    fn info(&self) -> BackendInfo {
596
        BackendInfo {
597
            runtime_type: RuntimeType::Vllm,
598
            version: "0.4.0".to_string(), // Would be detected from API
599
            supports_streaming: true,
600
            loaded_model: self.config.model.clone(),
601
        }
602
    }
603
604
    fn inference(&self, request: &InferenceRequest) -> Result<InferenceResponse, RealizarError> {
605
        // Parse URL to check for invalid port
606
        let url = &self.config.base_url;
607
        if let Some(port_str) = url.split(':').next_back() {
608
            if let Ok(port) = port_str.parse::<u32>() {
609
                if port > 65535 {
610
                    return Err(RealizarError::ConnectionError(format!(
611
                        "Invalid port in URL: {}",
612
                        url
613
                    )));
614
                }
615
            }
616
        }
617
618
        // REAL HTTP request to vLLM server via OpenAI-compatible API
619
        #[allow(clippy::cast_possible_truncation)]
620
        let completion_request = CompletionRequest {
621
            model: self
622
                .config
623
                .model
624
                .clone()
625
                .unwrap_or_else(|| "default".to_string()),
626
            prompt: request.prompt.clone(),
627
            max_tokens: request.max_tokens,
628
            temperature: Some(request.temperature as f32),
629
            stream: false,
630
        };
631
632
        let timing = self.http_client.openai_completion(
633
            &self.config.base_url,
634
            &completion_request,
635
            self.config.api_key.as_deref(),
636
        )?;
637
638
        Ok(InferenceResponse {
639
            text: timing.text,
640
            tokens_generated: timing.tokens_generated,
641
            ttft_ms: timing.ttft_ms,
642
            total_time_ms: timing.total_time_ms,
643
            itl_ms: vec![], // ITL requires streaming, not available in blocking mode
644
        })
645
    }
646
}
647
648
// ============================================================================
649
// OllamaBackend Implementation - REAL HTTP CALLS
650
// ============================================================================
651
652
/// Configuration for Ollama backend
653
#[cfg(feature = "bench-http")]
654
#[derive(Debug, Clone, Serialize, Deserialize)]
655
pub struct OllamaConfig {
656
    /// Base URL for Ollama server
657
    pub base_url: String,
658
    /// Model name
659
    pub model: String,
660
}
661
662
#[cfg(feature = "bench-http")]
663
impl Default for OllamaConfig {
664
    fn default() -> Self {
665
        Self {
666
            base_url: "http://localhost:11434".to_string(),
667
            model: "llama2".to_string(),
668
        }
669
    }
670
}
671
672
/// Ollama backend for inference via HTTP API
673
///
674
/// **REAL IMPLEMENTATION** - makes actual HTTP requests to Ollama servers.
675
/// No mock data. Measures real latency and throughput.
676
#[cfg(feature = "bench-http")]
677
pub struct OllamaBackend {
678
    config: OllamaConfig,
679
    http_client: ModelHttpClient,
680
}
681
682
#[cfg(feature = "bench-http")]
683
impl OllamaBackend {
684
    /// Create new Ollama backend with default HTTP client
685
    #[must_use]
686
    pub fn new(config: OllamaConfig) -> Self {
687
        Self {
688
            config,
689
            http_client: ModelHttpClient::new(),
690
        }
691
    }
692
693
    /// Create new Ollama backend with custom HTTP client
694
    #[must_use]
695
    pub fn with_client(config: OllamaConfig, client: ModelHttpClient) -> Self {
696
        Self {
697
            config,
698
            http_client: client,
699
        }
700
    }
701
}
702
703
#[cfg(feature = "bench-http")]
704
impl RuntimeBackend for OllamaBackend {
705
    fn info(&self) -> BackendInfo {
706
        BackendInfo {
707
            runtime_type: RuntimeType::Ollama,
708
            version: "0.1.0".to_string(), // Would be detected from API
709
            supports_streaming: true,
710
            loaded_model: Some(self.config.model.clone()),
711
        }
712
    }
713
714
    fn inference(&self, request: &InferenceRequest) -> Result<InferenceResponse, RealizarError> {
715
        // REAL HTTP request to Ollama server
716
        #[allow(clippy::cast_possible_truncation)]
717
        let ollama_request = OllamaRequest {
718
            model: self.config.model.clone(),
719
            prompt: request.prompt.clone(),
720
            stream: false,
721
            options: Some(OllamaOptions {
722
                num_predict: Some(request.max_tokens),
723
                temperature: Some(request.temperature as f32),
724
            }),
725
        };
726
727
        let timing = self
728
            .http_client
729
            .ollama_generate(&self.config.base_url, &ollama_request)?;
730
731
        Ok(InferenceResponse {
732
            text: timing.text,
733
            tokens_generated: timing.tokens_generated,
734
            ttft_ms: timing.ttft_ms,
735
            total_time_ms: timing.total_time_ms,
736
            itl_ms: vec![], // ITL requires streaming, not available in blocking mode
737
        })
738
    }
739
}
740