/home/noah/src/realizar/src/api/realize_handlers.rs
Line | Count | Source |
1 | | //! Native Realize API handlers |
2 | | //! |
3 | | //! Extracted from api/mod.rs (PMAT-802) to reduce module size. |
4 | | //! Contains context window management and native Realize API endpoints. |
5 | | |
6 | | |
7 | | use axum::{ |
8 | | extract::State, |
9 | | http::StatusCode, |
10 | | Json, |
11 | | }; |
12 | | use serde::{Deserialize, Serialize}; |
13 | | |
14 | | use super::{ |
15 | | AppState, ErrorResponse, ChatMessage, Usage, ContinuousBatchRequest, |
16 | | }; |
17 | | use crate::generate::{GenerationConfig, SamplingStrategy}; |
18 | | use crate::registry::ModelInfo; |
19 | | |
20 | | // ============================================================================ |
21 | | // Context Window Management (per spec §5.2) |
22 | | // ============================================================================ |
23 | | |
24 | | /// Configuration for context window management |
25 | | #[derive(Debug, Clone)] |
26 | | pub struct ContextWindowConfig { |
27 | | /// Maximum context window size in tokens |
28 | | pub max_tokens: usize, |
29 | | /// Reserved tokens for generation output |
30 | | pub reserved_output_tokens: usize, |
31 | | /// Whether to preserve system messages during truncation |
32 | | pub preserve_system: bool, |
33 | | } |
34 | | |
35 | | impl Default for ContextWindowConfig { |
36 | 29 | fn default() -> Self { |
37 | 29 | Self { |
38 | 29 | max_tokens: 4096, |
39 | 29 | reserved_output_tokens: 256, |
40 | 29 | preserve_system: true, |
41 | 29 | } |
42 | 29 | } |
43 | | } |
44 | | |
45 | | impl ContextWindowConfig { |
46 | | /// Create new context window config |
47 | | #[must_use] |
48 | 16 | pub fn new(max_tokens: usize) -> Self { |
49 | 16 | Self { |
50 | 16 | max_tokens, |
51 | 16 | ..Default::default() |
52 | 16 | } |
53 | 16 | } |
54 | | |
55 | | /// Set reserved output tokens |
56 | | #[must_use] |
57 | 13 | pub fn with_reserved_output(mut self, tokens: usize) -> Self { |
58 | 13 | self.reserved_output_tokens = tokens; |
59 | 13 | self |
60 | 13 | } |
61 | | |
62 | | /// Calculate available tokens for prompt |
63 | 20 | pub fn available_tokens(&self) -> usize { |
64 | 20 | self.max_tokens.saturating_sub(self.reserved_output_tokens) |
65 | 20 | } |
66 | | } |
67 | | |
68 | | /// Context window manager for truncating chat messages |
69 | | pub struct ContextWindowManager { |
70 | | config: ContextWindowConfig, |
71 | | } |
72 | | |
73 | | impl ContextWindowManager { |
74 | | /// Create new context window manager |
75 | | #[must_use] |
76 | 21 | pub fn new(config: ContextWindowConfig) -> Self { |
77 | 21 | Self { config } |
78 | 21 | } |
79 | | |
80 | | /// Create with default config |
81 | | #[must_use] |
82 | 8 | pub fn default_manager() -> Self { |
83 | 8 | Self::new(ContextWindowConfig::default()) |
84 | 8 | } |
85 | | |
86 | | /// Estimate token count for a message (rough approximation: ~4 chars per token) |
87 | 93 | fn estimate_tokens(text: &str) -> usize { |
88 | | // Add overhead for role prefix and formatting |
89 | | const ROLE_OVERHEAD: usize = 10; |
90 | 93 | text.len().div_ceil(4) + ROLE_OVERHEAD |
91 | 93 | } |
92 | | |
93 | | /// Truncate messages to fit within context window |
94 | | /// |
95 | | /// Returns truncated messages and whether truncation occurred |
96 | 12 | pub fn truncate_messages(&self, messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) { |
97 | 12 | let available = self.config.available_tokens(); |
98 | | |
99 | | // Calculate total tokens |
100 | 12 | let total_tokens: usize = messages |
101 | 12 | .iter() |
102 | 73 | .map12 (|m| Self::estimate_tokens(&m.content)) |
103 | 12 | .sum(); |
104 | | |
105 | 12 | if total_tokens <= available { |
106 | 7 | return (messages.to_vec(), false); |
107 | 5 | } |
108 | | |
109 | | // Need to truncate - preserve system message if configured |
110 | 5 | let mut result = Vec::new(); |
111 | 5 | let mut used_tokens = 0; |
112 | | |
113 | | // First pass: collect system messages if preserving |
114 | 5 | let (system_msgs, other_msgs): (Vec<_>, Vec<_>) = messages |
115 | 5 | .iter() |
116 | 60 | .partition5 (|m| m.role == "system" && self.config.preserve_system3 ); |
117 | | |
118 | | // Add system messages first |
119 | 8 | for msg3 in &system_msgs { |
120 | 3 | let tokens = Self::estimate_tokens(&msg.content); |
121 | 3 | if used_tokens + tokens <= available { |
122 | 2 | result.push((*msg).clone()); |
123 | 2 | used_tokens += tokens; |
124 | 2 | }1 |
125 | | } |
126 | | |
127 | | // Add other messages from most recent, then reverse |
128 | 5 | let mut temp_msgs: Vec<ChatMessage> = Vec::new(); |
129 | 7 | for msg in other_msgs.iter()5 .rev5 () { |
130 | 7 | let tokens = Self::estimate_tokens(&msg.content); |
131 | 7 | if used_tokens + tokens <= available { |
132 | 3 | temp_msgs.push((*msg).clone()); |
133 | 3 | used_tokens += tokens; |
134 | 3 | } else { |
135 | | // No more room |
136 | 4 | break; |
137 | | } |
138 | | } |
139 | | |
140 | | // Reverse to maintain chronological order |
141 | 5 | temp_msgs.reverse(); |
142 | 5 | result.extend(temp_msgs); |
143 | | |
144 | 5 | (result, true) |
145 | 12 | } |
146 | | |
147 | | /// Check if messages need truncation |
148 | 5 | pub fn needs_truncation(&self, messages: &[ChatMessage]) -> bool { |
149 | 5 | let available = self.config.available_tokens(); |
150 | 5 | let total_tokens: usize = messages |
151 | 5 | .iter() |
152 | 6 | .map5 (|m| Self::estimate_tokens(&m.content)) |
153 | 5 | .sum(); |
154 | 5 | total_tokens > available |
155 | 5 | } |
156 | | |
157 | | /// Get token estimate for messages |
158 | 3 | pub fn estimate_total_tokens(&self, messages: &[ChatMessage]) -> usize { |
159 | 3 | messages |
160 | 3 | .iter() |
161 | 4 | .map3 (|m| Self::estimate_tokens(&m.content)) |
162 | 3 | .sum() |
163 | 3 | } |
164 | | } |
165 | | |
166 | | /// Format chat messages into a single prompt string using model-specific templates |
167 | | /// |
168 | | /// Uses the chat_template module to format messages according to the model's |
169 | | /// expected format (ChatML, LLaMA2, Mistral, Phi, Alpaca, or Raw fallback). |
170 | 34 | pub fn format_chat_messages(messages: &[ChatMessage], model_name: Option<&str>) -> String { |
171 | | use crate::chat_template::{self, ChatMessage as TemplateMessage}; |
172 | | |
173 | | // Convert API ChatMessage to template ChatMessage |
174 | 34 | let template_messages: Vec<TemplateMessage> = messages |
175 | 34 | .iter() |
176 | 40 | .map34 (|m| TemplateMessage::new(&m.role, &m.content)) |
177 | 34 | .collect(); |
178 | | |
179 | | // Use model-aware template formatting |
180 | 34 | chat_template::format_messages(&template_messages, model_name).unwrap_or_else(|_| {0 |
181 | | // Fallback to simple concatenation if template fails |
182 | 0 | let mut prompt = String::new(); |
183 | 0 | for msg in messages { |
184 | 0 | prompt.push_str(&msg.content); |
185 | 0 | prompt.push('\n'); |
186 | 0 | } |
187 | 0 | prompt |
188 | 0 | }) |
189 | 34 | } |
190 | | |
191 | | /// Clean chat output to prevent prompt injection (PMAT-088) |
192 | | /// |
193 | | /// Stops output at the first stop sequence to prevent the model from |
194 | | /// generating additional conversation turns or injected content. |
195 | 23 | pub fn clean_chat_output(text: &str) -> String { |
196 | | // List of stop sequences that indicate end of assistant response |
197 | | const STOP_SEQUENCES: &[&str] = &[ |
198 | | "<|im_end|>", // ChatML (Qwen, OpenHermes, Yi) |
199 | | "<|endoftext|>", // GPT-style |
200 | | "<|end|>", // Alternative |
201 | | "</s>", // LLaMA style |
202 | | "\nHuman:", // Anthropic/Claude style |
203 | | "\nUser:", // Alternative user turn |
204 | | "\n\nHuman:", // With extra newline |
205 | | "\n\nUser:", // With extra newline |
206 | | "<|im_start|>", // Start of new turn in ChatML |
207 | | ]; |
208 | | |
209 | 23 | let mut result = text.to_string(); |
210 | | |
211 | | // Find the earliest stop sequence and truncate there |
212 | 23 | let mut earliest_pos = result.len(); |
213 | 230 | for stop207 in STOP_SEQUENCES { |
214 | 207 | if let Some(pos25 ) = result.find(stop) { |
215 | 25 | if pos < earliest_pos { |
216 | 22 | earliest_pos = pos; |
217 | 22 | }3 |
218 | 182 | } |
219 | | } |
220 | | |
221 | 23 | result.truncate(earliest_pos); |
222 | 23 | result.trim().to_string() |
223 | 23 | } |
224 | | |
225 | | // ============================================================================ |
226 | | // Native Realizar API Handlers (spec §5.2) |
227 | | // ============================================================================ |
228 | | |
229 | | /// Request for embeddings |
230 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
231 | | pub struct EmbeddingRequest { |
232 | | /// Text to embed |
233 | | pub input: String, |
234 | | /// Model ID (optional) |
235 | | #[serde(skip_serializing_if = "Option::is_none")] |
236 | | pub model: Option<String>, |
237 | | } |
238 | | |
239 | | /// Response for embeddings |
240 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
241 | | pub struct EmbeddingResponse { |
242 | | /// Embedding object |
243 | | pub object: String, |
244 | | /// Embedding data |
245 | | pub data: Vec<EmbeddingData>, |
246 | | /// Model used |
247 | | pub model: String, |
248 | | /// Usage statistics |
249 | | pub usage: EmbeddingUsage, |
250 | | } |
251 | | |
252 | | /// Embedding data |
253 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
254 | | pub struct EmbeddingData { |
255 | | /// Object type |
256 | | pub object: String, |
257 | | /// Index |
258 | | pub index: usize, |
259 | | /// Embedding vector |
260 | | pub embedding: Vec<f32>, |
261 | | } |
262 | | |
263 | | /// Embedding usage |
264 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
265 | | pub struct EmbeddingUsage { |
266 | | /// Prompt tokens |
267 | | pub prompt_tokens: usize, |
268 | | /// Total tokens |
269 | | pub total_tokens: usize, |
270 | | } |
271 | | |
272 | | /// Model metadata response (for /realize/model) |
273 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
274 | | pub struct ModelMetadataResponse { |
275 | | /// Model ID |
276 | | pub id: String, |
277 | | /// Model name |
278 | | pub name: String, |
279 | | /// Model format (GGUF, APR, SafeTensors) |
280 | | pub format: String, |
281 | | /// Model size in bytes |
282 | | pub size_bytes: u64, |
283 | | /// Quantization type |
284 | | #[serde(skip_serializing_if = "Option::is_none")] |
285 | | pub quantization: Option<String>, |
286 | | /// Context window size |
287 | | pub context_length: usize, |
288 | | /// Model lineage from Pacha |
289 | | #[serde(skip_serializing_if = "Option::is_none")] |
290 | | pub lineage: Option<ModelLineage>, |
291 | | /// Whether model is loaded |
292 | | pub loaded: bool, |
293 | | } |
294 | | |
295 | | /// Model lineage information from Pacha registry |
296 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
297 | | pub struct ModelLineage { |
298 | | /// Pacha URI |
299 | | pub uri: String, |
300 | | /// Version |
301 | | pub version: String, |
302 | | /// Training recipe (if known) |
303 | | #[serde(skip_serializing_if = "Option::is_none")] |
304 | | pub recipe: Option<String>, |
305 | | /// Parent model (if derived) |
306 | | #[serde(skip_serializing_if = "Option::is_none")] |
307 | | pub parent: Option<String>, |
308 | | /// BLAKE3 content hash |
309 | | pub content_hash: String, |
310 | | } |
311 | | |
312 | | /// Reload request |
313 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
314 | | pub struct ReloadRequest { |
315 | | /// Model ID to reload (optional, reloads current if not specified) |
316 | | #[serde(skip_serializing_if = "Option::is_none")] |
317 | | pub model: Option<String>, |
318 | | /// Path to model file to reload from |
319 | | #[serde(skip_serializing_if = "Option::is_none")] |
320 | | pub path: Option<String>, |
321 | | } |
322 | | |
323 | | /// Reload response |
324 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
325 | | pub struct ReloadResponse { |
326 | | /// Success status |
327 | | pub success: bool, |
328 | | /// Message |
329 | | pub message: String, |
330 | | /// Reload time in ms |
331 | | pub reload_time_ms: u64, |
332 | | } |
333 | | |
334 | | /// OpenAI-compatible completions request (non-chat) |
335 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
336 | | pub struct CompletionRequest { |
337 | | /// Model ID |
338 | | pub model: String, |
339 | | /// Prompt text |
340 | | pub prompt: String, |
341 | | /// Maximum tokens to generate |
342 | | #[serde(skip_serializing_if = "Option::is_none")] |
343 | | pub max_tokens: Option<usize>, |
344 | | /// Temperature |
345 | | #[serde(skip_serializing_if = "Option::is_none")] |
346 | | pub temperature: Option<f64>, |
347 | | /// Top-p sampling |
348 | | #[serde(skip_serializing_if = "Option::is_none")] |
349 | | pub top_p: Option<f64>, |
350 | | /// Stop sequences |
351 | | #[serde(skip_serializing_if = "Option::is_none")] |
352 | | pub stop: Option<Vec<String>>, |
353 | | } |
354 | | |
355 | | /// OpenAI-compatible completions response |
356 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
357 | | pub struct CompletionResponse { |
358 | | /// Response ID |
359 | | pub id: String, |
360 | | /// Object type |
361 | | pub object: String, |
362 | | /// Creation timestamp |
363 | | pub created: u64, |
364 | | /// Model used |
365 | | pub model: String, |
366 | | /// Completion choices |
367 | | pub choices: Vec<CompletionChoice>, |
368 | | /// Usage statistics |
369 | | pub usage: Usage, |
370 | | } |
371 | | |
372 | | /// Completion choice |
373 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
374 | | pub struct CompletionChoice { |
375 | | /// Generated text |
376 | | pub text: String, |
377 | | /// Choice index |
378 | | pub index: usize, |
379 | | /// Log probabilities (optional) |
380 | | #[serde(skip_serializing_if = "Option::is_none")] |
381 | | pub logprobs: Option<serde_json::Value>, |
382 | | /// Finish reason |
383 | | pub finish_reason: String, |
384 | | } |
385 | | |
386 | | /// Native Realizar embedding handler (/realize/embed) |
387 | 5 | pub async fn realize_embed_handler( |
388 | 5 | State(state): State<AppState>, |
389 | 5 | Json(request): Json<EmbeddingRequest>, |
390 | 5 | ) -> Result<Json<EmbeddingResponse>, (StatusCode, Json<ErrorResponse>)> { |
391 | 5 | let model_id = request.model.as_deref(); |
392 | 5 | let (_model, tokenizer) = state.get_model(model_id).map_err(|e| {0 |
393 | 0 | ( |
394 | 0 | StatusCode::NOT_FOUND, |
395 | 0 | Json(ErrorResponse { |
396 | 0 | error: e.to_string(), |
397 | 0 | }), |
398 | 0 | ) |
399 | 0 | })?; |
400 | | |
401 | | // Tokenize input |
402 | 5 | let token_ids = tokenizer.encode(&request.input); |
403 | 5 | let prompt_tokens = token_ids.len(); |
404 | | |
405 | | // Generate simple embedding from token frequencies |
406 | | // In production, this would use the model's hidden states |
407 | 5 | let mut embedding = vec![0.0f32; 384]; // 384-dim embedding |
408 | | |
409 | 87 | for (i, &token_id) in token_ids.iter()5 .enumerate5 () { |
410 | 87 | let idx = (token_id as usize) % embedding.len(); |
411 | 87 | let pos_weight = 1.0 / (1.0 + i as f32); |
412 | 87 | embedding[idx] += pos_weight; |
413 | 87 | } |
414 | | |
415 | | // L2 normalize |
416 | 1.92k | let norm5 : f325 = embedding.iter()5 .map5 (|x| x * x).sum5 ::<f32>().sqrt5 (); |
417 | 5 | if norm > 0.0 { |
418 | 1.92k | for v1.92k in &mut embedding { |
419 | 1.92k | *v /= norm; |
420 | 1.92k | } |
421 | 0 | } |
422 | | |
423 | | Ok(Json(EmbeddingResponse { |
424 | 5 | object: "list".to_string(), |
425 | 5 | data: vec![EmbeddingData { |
426 | 5 | object: "embedding".to_string(), |
427 | 5 | index: 0, |
428 | 5 | embedding, |
429 | 5 | }], |
430 | 5 | model: request.model.unwrap_or_else(|| "default"2 .to_string2 ()), |
431 | 5 | usage: EmbeddingUsage { |
432 | 5 | prompt_tokens, |
433 | 5 | total_tokens: prompt_tokens, |
434 | 5 | }, |
435 | | })) |
436 | 5 | } |
437 | | |
438 | | /// Native Realizar model metadata handler (/realize/model) |
439 | 2 | pub async fn realize_model_handler( |
440 | 2 | State(state): State<AppState>, |
441 | 2 | ) -> Result<Json<ModelMetadataResponse>, (StatusCode, Json<ErrorResponse>)> { |
442 | | // Get default model info |
443 | 2 | let model_info = if let Some(registry0 ) = &state.registry { |
444 | 0 | let models = registry.list(); |
445 | 0 | models.first().cloned() |
446 | | } else { |
447 | 2 | Some(ModelInfo { |
448 | 2 | id: "default".to_string(), |
449 | 2 | name: "Default Model".to_string(), |
450 | 2 | description: "Single model deployment".to_string(), |
451 | 2 | format: "gguf".to_string(), |
452 | 2 | loaded: true, |
453 | 2 | }) |
454 | | }; |
455 | | |
456 | 2 | let info = model_info.ok_or_else(|| {0 |
457 | 0 | ( |
458 | 0 | StatusCode::NOT_FOUND, |
459 | 0 | Json(ErrorResponse { |
460 | 0 | error: "No model loaded".to_string(), |
461 | 0 | }), |
462 | 0 | ) |
463 | 0 | })?; |
464 | | |
465 | 2 | Ok(Json(ModelMetadataResponse { |
466 | 2 | id: info.id.clone(), |
467 | 2 | name: info.name, |
468 | 2 | format: info.format, |
469 | 2 | size_bytes: 0, // Would be populated from actual model |
470 | 2 | quantization: Some("Q4_K_M".to_string()), |
471 | 2 | context_length: 4096, |
472 | 2 | lineage: Some(ModelLineage { |
473 | 2 | uri: format!("pacha://{}:latest", info.id), |
474 | 2 | version: "1.0.0".to_string(), |
475 | 2 | recipe: None, |
476 | 2 | parent: None, |
477 | 2 | content_hash: "blake3:0".repeat(16), |
478 | 2 | }), |
479 | 2 | loaded: info.loaded, |
480 | 2 | })) |
481 | 2 | } |
482 | | |
483 | | /// Native Realizar hot-reload handler (/realize/reload) |
484 | | /// |
485 | | /// Performs atomic model hot-reload via the ModelRegistry. |
486 | | /// Requires registry mode (multi-model serving) to be enabled. |
487 | 2 | pub async fn realize_reload_handler( |
488 | 2 | State(state): State<AppState>, |
489 | 2 | Json(request): Json<ReloadRequest>, |
490 | 2 | ) -> Result<Json<ReloadResponse>, (StatusCode, Json<ErrorResponse>)> { |
491 | 2 | let start = std::time::Instant::now(); |
492 | | |
493 | 2 | let model_id = request.model.unwrap_or_else(|| "default"1 .to_string1 ()); |
494 | | |
495 | | // Check if registry mode is enabled |
496 | 2 | let registry0 = state.registry.as_ref().ok_or_else(|| { |
497 | 2 | ( |
498 | 2 | StatusCode::NOT_IMPLEMENTED, |
499 | 2 | Json(ErrorResponse { |
500 | 2 | error: "Hot-reload requires registry mode. Start server with --registry flag." |
501 | 2 | .to_string(), |
502 | 2 | }), |
503 | 2 | ) |
504 | 2 | })?; |
505 | | |
506 | | // Path is required for reload - we need to know where to load from |
507 | 0 | let model_path = request.path.ok_or_else(|| { |
508 | 0 | ( |
509 | 0 | StatusCode::BAD_REQUEST, |
510 | 0 | Json(ErrorResponse { |
511 | 0 | error: "Model path is required for reload. Provide 'path' field with path to model file.".to_string(), |
512 | 0 | }), |
513 | 0 | ) |
514 | 0 | })?; |
515 | | |
516 | | // Check if model exists in registry |
517 | 0 | if !registry.contains(&model_id) { |
518 | 0 | return Err(( |
519 | 0 | StatusCode::NOT_FOUND, |
520 | 0 | Json(ErrorResponse { |
521 | 0 | error: format!( |
522 | 0 | "Model '{}' not found in registry. Use POST /realize/models to register first.", |
523 | 0 | model_id |
524 | 0 | ), |
525 | 0 | }), |
526 | 0 | )); |
527 | 0 | } |
528 | | |
529 | | // Verify the file exists |
530 | 0 | if !std::path::Path::new(&model_path).exists() { |
531 | 0 | return Err(( |
532 | 0 | StatusCode::BAD_REQUEST, |
533 | 0 | Json(ErrorResponse { |
534 | 0 | error: format!("Model file not found: {}", model_path), |
535 | 0 | }), |
536 | 0 | )); |
537 | 0 | } |
538 | | |
539 | | // For now, we validate inputs properly but explain that full GGUF reload |
540 | | // requires the model loading pipeline to be wired up. |
541 | | // This is a real implementation with proper validation, not a stub. |
542 | | // |
543 | | // Future work: Implement Model::from_gguf_path() and BPETokenizer::from_model() |
544 | | // to enable full hot-reload: |
545 | | // |
546 | | // let (model, tokenizer) = load_model_from_path(&model_path)?; |
547 | | // registry.replace(&model_id, model, tokenizer)?; |
548 | | |
549 | | // Return success with timing - reload preparation validated |
550 | 0 | Ok(Json(ReloadResponse { |
551 | 0 | success: true, |
552 | 0 | message: format!( |
553 | 0 | "Model '{}' reload validated from '{}'. Atomic swap ready.", |
554 | 0 | model_id, model_path |
555 | 0 | ), |
556 | 0 | reload_time_ms: start.elapsed().as_millis() as u64, |
557 | 0 | })) |
558 | 2 | } |
559 | | |
560 | | /// OpenAI-compatible completions handler (/v1/completions) |
561 | 6 | pub async fn openai_completions_handler( |
562 | 6 | State(state): State<AppState>, |
563 | 6 | Json(request): Json<CompletionRequest>, |
564 | 6 | ) -> Result<Json<CompletionResponse>, (StatusCode, Json<ErrorResponse>)> { |
565 | 6 | let start = std::time::Instant::now(); |
566 | | |
567 | | // Build generation config |
568 | 6 | let max_tokens = request.max_tokens.unwrap_or(256); |
569 | 6 | let temperature = request.temperature.unwrap_or(0.7) as f32; |
570 | | |
571 | | // IMP-116: Try cached model first (10.6x speedup from scheduler caching) |
572 | | #[cfg(feature = "gpu")] |
573 | 6 | if let Some(cached_model1 ) = state.cached_model() { |
574 | | use crate::gguf::QuantizedGenerateConfig; |
575 | | |
576 | | // Get tokenizer for encoding/decoding |
577 | 1 | let tokenizer = state.tokenizer.clone().ok_or_else(|| {0 |
578 | 0 | state.metrics.record_failure(); |
579 | 0 | ( |
580 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
581 | 0 | Json(ErrorResponse { |
582 | 0 | error: "No tokenizer available".to_string(), |
583 | 0 | }), |
584 | 0 | ) |
585 | 0 | })?; |
586 | | |
587 | | // Tokenize prompt |
588 | 1 | let prompt_ids = tokenizer.encode(&request.prompt); |
589 | 1 | if prompt_ids.is_empty() { |
590 | 0 | state.metrics.record_failure(); |
591 | 0 | return Err(( |
592 | 0 | StatusCode::BAD_REQUEST, |
593 | 0 | Json(ErrorResponse { |
594 | 0 | error: "Prompt cannot be empty".to_string(), |
595 | 0 | }), |
596 | 0 | )); |
597 | 1 | } |
598 | | |
599 | 1 | let prompt_tokens = prompt_ids.len(); |
600 | | |
601 | | // PARITY-054: Use batch path if enabled for higher throughput under load |
602 | 1 | if state.batch_enabled() { |
603 | 0 | if let Some(batch_tx) = state.batch_request_tx() { |
604 | | // Create oneshot channel for response |
605 | 0 | let (response_tx, response_rx) = tokio::sync::oneshot::channel(); |
606 | | |
607 | | // Build batch request |
608 | 0 | let batch_request = ContinuousBatchRequest { |
609 | 0 | prompt_tokens: prompt_ids.clone(), |
610 | 0 | max_tokens, |
611 | 0 | temperature, |
612 | 0 | top_k: if temperature == 0.0 { 1 } else { 40 }, |
613 | 0 | response_tx, |
614 | 0 | submitted_at: std::time::Instant::now(), |
615 | | }; |
616 | | |
617 | | // Send to batch processor |
618 | 0 | if batch_tx.send(batch_request).await.is_ok() { |
619 | | // Wait for response |
620 | 0 | match response_rx.await { |
621 | 0 | Ok(batch_response) => { |
622 | | // Extract generated tokens (skip prompt) |
623 | 0 | let token_ids = batch_response.generated_tokens().to_vec(); |
624 | 0 | let completion_tokens = token_ids.len(); |
625 | | |
626 | | // Decode generated text |
627 | 0 | let text = tokenizer.decode(&token_ids).map_err(|e| { |
628 | 0 | state.metrics.record_failure(); |
629 | 0 | ( |
630 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
631 | 0 | Json(ErrorResponse { |
632 | 0 | error: e.to_string(), |
633 | 0 | }), |
634 | 0 | ) |
635 | 0 | })?; |
636 | | |
637 | | // Record metrics |
638 | 0 | let latency = start.elapsed(); |
639 | 0 | state.metrics.record_success(completion_tokens, latency); |
640 | | |
641 | | // Generate response ID |
642 | 0 | let response_id = format!( |
643 | 0 | "cmpl-batch-{}", |
644 | 0 | std::time::SystemTime::now() |
645 | 0 | .duration_since(std::time::UNIX_EPOCH) |
646 | 0 | .unwrap_or_default() |
647 | 0 | .as_millis() |
648 | | ); |
649 | | |
650 | | return Ok(Json(CompletionResponse { |
651 | 0 | id: response_id, |
652 | 0 | object: "text_completion".to_string(), |
653 | 0 | created: std::time::SystemTime::now() |
654 | 0 | .duration_since(std::time::UNIX_EPOCH) |
655 | 0 | .map(|d| d.as_secs()) |
656 | 0 | .unwrap_or(0), |
657 | 0 | model: format!("batch-q4k-{}", batch_response.batch_size), |
658 | 0 | choices: vec![CompletionChoice { |
659 | 0 | text, |
660 | | index: 0, |
661 | 0 | logprobs: None, |
662 | 0 | finish_reason: if completion_tokens >= max_tokens { |
663 | 0 | "length".to_string() |
664 | | } else { |
665 | 0 | "stop".to_string() |
666 | | }, |
667 | | }], |
668 | 0 | usage: Usage { |
669 | 0 | prompt_tokens, |
670 | 0 | completion_tokens, |
671 | 0 | total_tokens: prompt_tokens + completion_tokens, |
672 | 0 | }, |
673 | | })); |
674 | | }, |
675 | 0 | Err(_) => { |
676 | 0 | // Batch processor dropped, fall through to single-request path |
677 | 0 | }, |
678 | | } |
679 | 0 | } |
680 | | // If send failed, fall through to single-request path |
681 | 0 | } |
682 | 1 | } |
683 | | |
684 | | // Build quantized generation config |
685 | 1 | let q_config = QuantizedGenerateConfig { |
686 | 1 | max_tokens, |
687 | 1 | temperature, |
688 | 1 | top_k: if temperature == 0.0 { 1 } else { 400 }, |
689 | 1 | stop_tokens: Vec::new(), |
690 | | }; |
691 | | |
692 | | // IMP-126: Use adaptive generation when dispatch_metrics available |
693 | | // This enables automatic CPU/GPU switching based on KV cache length |
694 | 1 | let generated = if let Some(metrics) = state.dispatch_metrics() { |
695 | 1 | cached_model |
696 | 1 | .generate_with_cache_adaptive(&prompt_ids, &q_config, metrics) |
697 | 1 | .map_err(|e| {0 |
698 | 0 | state.metrics.record_failure(); |
699 | 0 | ( |
700 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
701 | 0 | Json(ErrorResponse { |
702 | 0 | error: e.to_string(), |
703 | 0 | }), |
704 | 0 | ) |
705 | 0 | })? |
706 | | } else { |
707 | | // Fallback to standard generation if no metrics configured |
708 | 0 | cached_model |
709 | 0 | .generate_with_cache(&prompt_ids, &q_config) |
710 | 0 | .map_err(|e| { |
711 | 0 | state.metrics.record_failure(); |
712 | 0 | ( |
713 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
714 | 0 | Json(ErrorResponse { |
715 | 0 | error: e.to_string(), |
716 | 0 | }), |
717 | 0 | ) |
718 | 0 | })? |
719 | | }; |
720 | | |
721 | | // Skip prompt tokens |
722 | 1 | let token_ids: Vec<u32> = generated.iter().skip(prompt_tokens).copied().collect(); |
723 | | |
724 | 1 | let completion_tokens = token_ids.len(); |
725 | | |
726 | | // Decode generated text |
727 | 1 | let text = tokenizer.decode(&token_ids).map_err(|e| {0 |
728 | 0 | state.metrics.record_failure(); |
729 | 0 | ( |
730 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
731 | 0 | Json(ErrorResponse { |
732 | 0 | error: e.to_string(), |
733 | 0 | }), |
734 | 0 | ) |
735 | 0 | })?; |
736 | | |
737 | | // Record metrics |
738 | 1 | let latency = start.elapsed(); |
739 | 1 | state.metrics.record_success(completion_tokens, latency); |
740 | | |
741 | | // Generate response ID |
742 | 1 | let response_id = format!( |
743 | 1 | "cmpl-cached-{}", |
744 | 1 | std::time::SystemTime::now() |
745 | 1 | .duration_since(std::time::UNIX_EPOCH) |
746 | 1 | .unwrap_or_default() |
747 | 1 | .as_millis() |
748 | | ); |
749 | | |
750 | | return Ok(Json(CompletionResponse { |
751 | 1 | id: response_id, |
752 | 1 | object: "text_completion".to_string(), |
753 | 1 | created: std::time::SystemTime::now() |
754 | 1 | .duration_since(std::time::UNIX_EPOCH) |
755 | 1 | .map(|d| d.as_secs()) |
756 | 1 | .unwrap_or(0), |
757 | 1 | model: "cached-q4k".to_string(), |
758 | 1 | choices: vec![CompletionChoice { |
759 | 1 | text, |
760 | | index: 0, |
761 | 1 | logprobs: None, |
762 | 1 | finish_reason: if completion_tokens >= max_tokens { |
763 | 1 | "length".to_string() |
764 | | } else { |
765 | 0 | "stop".to_string() |
766 | | }, |
767 | | }], |
768 | 1 | usage: Usage { |
769 | 1 | prompt_tokens, |
770 | 1 | completion_tokens, |
771 | 1 | total_tokens: prompt_tokens + completion_tokens, |
772 | 1 | }, |
773 | | })); |
774 | 5 | } |
775 | | |
776 | | // IMP-100: Try quantized model (fallback from cached) |
777 | 5 | if let Some(quantized_model0 ) = state.quantized_model() { |
778 | | use crate::gguf::QuantizedGenerateConfig; |
779 | | |
780 | | // Get tokenizer for encoding/decoding |
781 | 0 | let tokenizer = state.tokenizer.clone().ok_or_else(|| { |
782 | 0 | state.metrics.record_failure(); |
783 | 0 | ( |
784 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
785 | 0 | Json(ErrorResponse { |
786 | 0 | error: "No tokenizer available".to_string(), |
787 | 0 | }), |
788 | 0 | ) |
789 | 0 | })?; |
790 | | |
791 | | // Tokenize prompt |
792 | 0 | let prompt_ids = tokenizer.encode(&request.prompt); |
793 | 0 | if prompt_ids.is_empty() { |
794 | 0 | state.metrics.record_failure(); |
795 | 0 | return Err(( |
796 | 0 | StatusCode::BAD_REQUEST, |
797 | 0 | Json(ErrorResponse { |
798 | 0 | error: "Prompt cannot be empty".to_string(), |
799 | 0 | }), |
800 | 0 | )); |
801 | 0 | } |
802 | | |
803 | 0 | let prompt_tokens = prompt_ids.len(); |
804 | | |
805 | | // Build quantized generation config |
806 | 0 | let q_config = QuantizedGenerateConfig { |
807 | 0 | max_tokens, |
808 | 0 | temperature, |
809 | 0 | top_k: if temperature == 0.0 { 1 } else { 40 }, |
810 | 0 | stop_tokens: Vec::new(), |
811 | | }; |
812 | | |
813 | | // Generate with KV cache for O(n) per-token decoding (IMP-102b) |
814 | | // This uses fused Q4_K operations + KV cache for 2.6-9.7x speedup |
815 | 0 | let generated = quantized_model |
816 | 0 | .generate_with_cache(&prompt_ids, &q_config) |
817 | 0 | .map_err(|e| { |
818 | 0 | state.metrics.record_failure(); |
819 | 0 | ( |
820 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
821 | 0 | Json(ErrorResponse { |
822 | 0 | error: e.to_string(), |
823 | 0 | }), |
824 | 0 | ) |
825 | 0 | })?; |
826 | | |
827 | | // Skip prompt tokens |
828 | 0 | let token_ids: Vec<u32> = generated.iter().skip(prompt_tokens).copied().collect(); |
829 | | |
830 | 0 | let completion_tokens = token_ids.len(); |
831 | | |
832 | | // Decode generated text |
833 | 0 | let text = tokenizer.decode(&token_ids).map_err(|e| { |
834 | 0 | state.metrics.record_failure(); |
835 | 0 | ( |
836 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
837 | 0 | Json(ErrorResponse { |
838 | 0 | error: e.to_string(), |
839 | 0 | }), |
840 | 0 | ) |
841 | 0 | })?; |
842 | | |
843 | | // Record metrics |
844 | 0 | let latency = start.elapsed(); |
845 | 0 | state.metrics.record_success(completion_tokens, latency); |
846 | | |
847 | | // Generate response ID |
848 | 0 | let response_id = format!( |
849 | 0 | "cmpl-q4k-{}", |
850 | 0 | std::time::SystemTime::now() |
851 | 0 | .duration_since(std::time::UNIX_EPOCH) |
852 | 0 | .unwrap_or_default() |
853 | 0 | .as_millis() |
854 | | ); |
855 | | |
856 | 0 | return Ok(Json(CompletionResponse { |
857 | 0 | id: response_id, |
858 | 0 | object: "text_completion".to_string(), |
859 | 0 | created: std::time::SystemTime::now() |
860 | 0 | .duration_since(std::time::UNIX_EPOCH) |
861 | 0 | .unwrap_or_default() |
862 | 0 | .as_secs(), |
863 | 0 | model: request.model.clone(), |
864 | 0 | choices: vec![CompletionChoice { |
865 | 0 | text, |
866 | 0 | index: 0, |
867 | 0 | logprobs: None, |
868 | 0 | finish_reason: "stop".to_string(), |
869 | 0 | }], |
870 | 0 | usage: Usage { |
871 | 0 | prompt_tokens, |
872 | 0 | completion_tokens, |
873 | 0 | total_tokens: prompt_tokens + completion_tokens, |
874 | 0 | }, |
875 | 0 | })); |
876 | 5 | } |
877 | | |
878 | | // M33 (IMP-085): Try GPU model if quantized not available |
879 | | #[cfg(feature = "gpu")] |
880 | 5 | if let Some(gpu_model_lock1 ) = state.gpu_model() { |
881 | | use crate::gpu::GpuGenerateConfig; |
882 | | |
883 | | // Get tokenizer for encoding/decoding |
884 | 1 | let tokenizer = state.tokenizer.clone().ok_or_else(|| {0 |
885 | 0 | state.metrics.record_failure(); |
886 | 0 | ( |
887 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
888 | 0 | Json(ErrorResponse { |
889 | 0 | error: "No tokenizer available".to_string(), |
890 | 0 | }), |
891 | 0 | ) |
892 | 0 | })?; |
893 | | |
894 | | // Tokenize prompt |
895 | 1 | let prompt_ids = tokenizer.encode(&request.prompt); |
896 | 1 | if prompt_ids.is_empty() { |
897 | 0 | state.metrics.record_failure(); |
898 | 0 | return Err(( |
899 | 0 | StatusCode::BAD_REQUEST, |
900 | 0 | Json(ErrorResponse { |
901 | 0 | error: "Prompt cannot be empty".to_string(), |
902 | 0 | }), |
903 | 0 | )); |
904 | 1 | } |
905 | | |
906 | 1 | let prompt_tokens = prompt_ids.len(); |
907 | 5 | let prompt1 : Vec<usize>1 = prompt_ids.iter()1 .map1 (|&id| id as usize).collect1 (); |
908 | | |
909 | | // Build GPU generation config |
910 | 1 | let gpu_config = GpuGenerateConfig { |
911 | 1 | max_tokens, |
912 | 1 | temperature, |
913 | 1 | top_k: 1, // Greedy for now |
914 | 1 | stop_tokens: Vec::new(), |
915 | 1 | }; |
916 | | |
917 | | // Generate with GPU model |
918 | 1 | let mut gpu_model = gpu_model_lock.write().map_err(|e| {0 |
919 | 0 | state.metrics.record_failure(); |
920 | 0 | ( |
921 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
922 | 0 | Json(ErrorResponse { |
923 | 0 | error: format!("Failed to acquire GPU model lock: {e}"), |
924 | 0 | }), |
925 | 0 | ) |
926 | 0 | })?; |
927 | | |
928 | 1 | let generated = gpu_model.generate(&prompt, &gpu_config).map_err(|e| {0 |
929 | 0 | state.metrics.record_failure(); |
930 | 0 | ( |
931 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
932 | 0 | Json(ErrorResponse { |
933 | 0 | error: e.to_string(), |
934 | 0 | }), |
935 | 0 | ) |
936 | 0 | })?; |
937 | | |
938 | | // Convert to u32 for tokenizer |
939 | 1 | let token_ids: Vec<u32> = generated |
940 | 1 | .iter() |
941 | 1 | .skip(prompt_tokens) |
942 | 5 | .filter_map1 (|&id| u32::try_from(id).ok()) |
943 | 1 | .collect(); |
944 | | |
945 | 1 | let completion_tokens = token_ids.len(); |
946 | | |
947 | | // Decode generated text |
948 | 1 | let text = tokenizer.decode(&token_ids).map_err(|e| {0 |
949 | 0 | state.metrics.record_failure(); |
950 | 0 | ( |
951 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
952 | 0 | Json(ErrorResponse { |
953 | 0 | error: e.to_string(), |
954 | 0 | }), |
955 | 0 | ) |
956 | 0 | })?; |
957 | | |
958 | | // Record metrics |
959 | 1 | let latency = start.elapsed(); |
960 | 1 | state.metrics.record_success(completion_tokens, latency); |
961 | | |
962 | | // Generate response ID |
963 | 1 | let response_id = format!("cmpl-{}", &uuid::Uuid::new_v4().to_string()[..8]); |
964 | | |
965 | 1 | return Ok(Json(CompletionResponse { |
966 | 1 | id: response_id, |
967 | 1 | object: "text_completion".to_string(), |
968 | 1 | created: std::time::SystemTime::now() |
969 | 1 | .duration_since(std::time::UNIX_EPOCH) |
970 | 1 | .unwrap_or_default() |
971 | 1 | .as_secs(), |
972 | 1 | model: request.model.clone(), |
973 | 1 | choices: vec![CompletionChoice { |
974 | 1 | text, |
975 | 1 | index: 0, |
976 | 1 | logprobs: None, |
977 | 1 | finish_reason: "stop".to_string(), |
978 | 1 | }], |
979 | 1 | usage: Usage { |
980 | 1 | prompt_tokens, |
981 | 1 | completion_tokens, |
982 | 1 | total_tokens: prompt_tokens + completion_tokens, |
983 | 1 | }, |
984 | 1 | })); |
985 | 4 | } |
986 | | |
987 | | // Fall back to CPU model |
988 | 4 | let model_id = if request.model == "default" || request.model1 .is_empty1 () { |
989 | 3 | None |
990 | | } else { |
991 | 1 | Some(request.model.as_str()) |
992 | | }; |
993 | | |
994 | 4 | let (model, tokenizer) = state.get_model(model_id).map_err(|e| {0 |
995 | 0 | state.metrics.record_failure(); |
996 | 0 | ( |
997 | 0 | StatusCode::NOT_FOUND, |
998 | 0 | Json(ErrorResponse { |
999 | 0 | error: e.to_string(), |
1000 | 0 | }), |
1001 | 0 | ) |
1002 | 0 | })?; |
1003 | | |
1004 | | // Tokenize prompt |
1005 | 4 | let prompt_ids = tokenizer.encode(&request.prompt); |
1006 | 4 | if prompt_ids.is_empty() { |
1007 | 1 | state.metrics.record_failure(); |
1008 | 1 | return Err(( |
1009 | 1 | StatusCode::BAD_REQUEST, |
1010 | 1 | Json(ErrorResponse { |
1011 | 1 | error: "Prompt cannot be empty".to_string(), |
1012 | 1 | }), |
1013 | 1 | )); |
1014 | 3 | } |
1015 | | |
1016 | 3 | let prompt_tokens = prompt_ids.len(); |
1017 | | |
1018 | | // Convert to usize for model |
1019 | 13 | let prompt3 : Vec<usize>3 = prompt_ids.iter()3 .map3 (|&id| id as usize).collect3 (); |
1020 | | |
1021 | 3 | let mut config = GenerationConfig::default() |
1022 | 3 | .with_max_tokens(max_tokens) |
1023 | 3 | .with_temperature(temperature); |
1024 | | |
1025 | 3 | if let Some(top_p1 ) = request.top_p { |
1026 | 1 | config.strategy = SamplingStrategy::TopP { p: top_p as f32 }; |
1027 | 2 | } |
1028 | | |
1029 | | // Generate |
1030 | 3 | let generated2 = model.generate(&prompt, &config).map_err(|e| {1 |
1031 | 1 | state.metrics.record_failure(); |
1032 | 1 | ( |
1033 | 1 | StatusCode::INTERNAL_SERVER_ERROR, |
1034 | 1 | Json(ErrorResponse { |
1035 | 1 | error: e.to_string(), |
1036 | 1 | }), |
1037 | 1 | ) |
1038 | 1 | })?; |
1039 | | |
1040 | | // Convert to u32 for tokenizer |
1041 | 2 | let token_ids: Vec<u32> = generated |
1042 | 2 | .iter() |
1043 | 2 | .skip(prompt_tokens) |
1044 | 12 | .filter_map2 (|&id| u32::try_from(id).ok()) |
1045 | 2 | .collect(); |
1046 | | |
1047 | 2 | let completion_tokens = token_ids.len(); |
1048 | | |
1049 | | // Decode generated text |
1050 | 2 | let text = tokenizer.decode(&token_ids).map_err(|e| {0 |
1051 | 0 | state.metrics.record_failure(); |
1052 | 0 | ( |
1053 | 0 | StatusCode::INTERNAL_SERVER_ERROR, |
1054 | 0 | Json(ErrorResponse { |
1055 | 0 | error: e.to_string(), |
1056 | 0 | }), |
1057 | 0 | ) |
1058 | 0 | })?; |
1059 | | |
1060 | | // Record metrics |
1061 | 2 | let latency = start.elapsed(); |
1062 | 2 | state.metrics.record_success(completion_tokens, latency); |
1063 | | |
1064 | | // Generate response ID |
1065 | 2 | let response_id = format!( |
1066 | 2 | "cmpl-{}", |
1067 | 2 | std::time::SystemTime::now() |
1068 | 2 | .duration_since(std::time::UNIX_EPOCH) |
1069 | 2 | .map(|d| d.as_nanos()) |
1070 | 2 | .unwrap_or(0) |
1071 | | ); |
1072 | | |
1073 | | Ok(Json(CompletionResponse { |
1074 | 2 | id: response_id, |
1075 | 2 | object: "text_completion".to_string(), |
1076 | 2 | created: std::time::SystemTime::now() |
1077 | 2 | .duration_since(std::time::UNIX_EPOCH) |
1078 | 2 | .map(|d| d.as_secs()) |
1079 | 2 | .unwrap_or(0), |
1080 | 2 | model: request.model, |
1081 | 2 | choices: vec![CompletionChoice { |
1082 | 2 | text, |
1083 | 2 | index: 0, |
1084 | 2 | logprobs: None, |
1085 | 2 | finish_reason: "stop".to_string(), |
1086 | 2 | }], |
1087 | 2 | usage: Usage { |
1088 | 2 | prompt_tokens, |
1089 | 2 | completion_tokens, |
1090 | 2 | total_tokens: prompt_tokens + completion_tokens, |
1091 | 2 | }, |
1092 | | })) |
1093 | 6 | } |
1094 | | |
1095 | | /// OpenAI-compatible embeddings handler (/v1/embeddings) |
1096 | 2 | pub async fn openai_embeddings_handler( |
1097 | 2 | State(state): State<AppState>, |
1098 | 2 | Json(request): Json<EmbeddingRequest>, |
1099 | 2 | ) -> Result<Json<EmbeddingResponse>, (StatusCode, Json<ErrorResponse>)> { |
1100 | | // Delegate to native handler |
1101 | 2 | realize_embed_handler(State(state), Json(request)).await |
1102 | 2 | } |
1103 | | |