/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 | | |