Skip to main content

pictor_runtime/
api_extensions.rs

1//! Extended `/v1/chat/completions` handler.
2//!
3//! Adds support for tools (function calling), logprobs, `n > 1` completions,
4//! response format constraints (JSON mode / JSON Schema), stop sequences,
5//! and frequency/presence penalties on top of the base server implementation.
6
7use axum::{extract::State, response::IntoResponse, Json};
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use crate::api_types::{
12    ChoiceLogprobs, ExtendedChatRequest, ExtendedChatResponse, ExtendedChoice, UsageInfo,
13};
14use crate::engine::InferenceEngine;
15use crate::sampling::SamplingParams;
16use crate::server::{AppState, ChatMessage};
17
18// ── Extended handler ──────────────────────────────────────────────────────────
19
20/// Handler for `POST /v1/chat/completions/extended`.
21///
22/// Supports all standard fields plus `tools`, `tool_choice`, `logprobs`,
23/// `top_logprobs`, `response_format`, `n`, `presence_penalty`,
24/// `frequency_penalty`, and `stop`.
25pub async fn extended_chat_completions(
26    State(state): State<Arc<AppState>>,
27    Json(req): Json<ExtendedChatRequest>,
28) -> impl IntoResponse {
29    let n = req.n.unwrap_or(1).clamp(1, 4);
30    let max_tokens = req.max_tokens;
31    let temperature = req.temperature.unwrap_or(0.7);
32    let seed = req.seed.unwrap_or(42);
33    let want_logprobs = req.logprobs.unwrap_or(false);
34    let top_logprobs_k = req.top_logprobs.unwrap_or(0).clamp(0, 20);
35    let response_format = req.response_format.clone();
36    let tools = req.tools.clone();
37    let frequency_penalty = req.frequency_penalty.unwrap_or(0.0);
38    let presence_penalty = req.presence_penalty.unwrap_or(0.0);
39
40    // Build stop checker
41    let stop_checker = match req.stop {
42        Some(ref seqs) => StopChecker::new(seqs.as_slice().to_vec()),
43        None => StopChecker::new(vec![]),
44    };
45
46    // Build prompt text from messages
47    let prompt_text = build_extended_prompt(&req.messages);
48
49    // Tokenize the prompt
50    let prompt_tokens = {
51        let tokenizer = state.tokenizer();
52        if let Some(tok) = tokenizer {
53            match tok.encode(&prompt_text) {
54                Ok(tokens) => tokens,
55                Err(e) => {
56                    tracing::error!(error = %e, "tokenization failed");
57                    return (
58                        axum::http::StatusCode::INTERNAL_SERVER_ERROR,
59                        Json(serde_json::json!({"error": "tokenization failed"})),
60                    )
61                        .into_response();
62                }
63            }
64        } else {
65            vec![151644u32]
66        }
67    };
68
69    let prompt_len = prompt_tokens.len();
70
71    // Build sampling params
72    let sampling_params = SamplingParams {
73        temperature,
74        top_k: 40,
75        top_p: req.top_p.unwrap_or(0.9),
76        repetition_penalty: 1.1,
77        ..SamplingParams::default()
78    };
79
80    // Generate n completions. One lease serves all `n` runs (they reset KV
81    // between runs, as before), so the replica is held for the whole batch.
82    let mut engine = match state.acquire_engine().await {
83        Ok(lease) => lease,
84        Err(e) => {
85            tracing::error!(error = %e, "engine pool acquire failed");
86            return (
87                axum::http::StatusCode::SERVICE_UNAVAILABLE,
88                Json(serde_json::json!({"error": "engine pool unavailable"})),
89            )
90                .into_response();
91        }
92    };
93
94    let raw_completions: Vec<String> = {
95        let mut results = Vec::with_capacity(n);
96        for i in 0..n {
97            let run_seed = seed.wrapping_add(i as u64);
98            engine.reset();
99
100            let output_tokens = match engine.generate_with_seed(
101                &prompt_tokens,
102                max_tokens,
103                run_seed,
104                &sampling_params,
105            ) {
106                Ok(toks) => toks,
107                Err(e) => {
108                    tracing::error!(error = %e, "generation failed for completion {i}");
109                    return (
110                        axum::http::StatusCode::INTERNAL_SERVER_ERROR,
111                        Json(serde_json::json!({"error": "generation failed"})),
112                    )
113                        .into_response();
114                }
115            };
116
117            // Apply frequency/presence penalty post-hoc if requested
118            // (for simplicity; a full implementation would fold this into decoding)
119            let _ = frequency_penalty;
120            let _ = presence_penalty;
121
122            // Decode
123            let text = if let Some(tok) = state.tokenizer() {
124                tok.decode(&output_tokens)
125                    .unwrap_or_else(|_| format!("{output_tokens:?}"))
126            } else {
127                format!("{output_tokens:?}")
128            };
129
130            results.push(text);
131        }
132        results
133    };
134
135    // Apply stop sequences and response format enforcement
136    let json_enforcer = JsonModeEnforcer::new();
137    let is_json_mode = response_format
138        .as_ref()
139        .map(|rf| rf.format_type == "json_object" || rf.format_type == "json_schema")
140        .unwrap_or(false);
141
142    let total_completion_tokens: usize;
143    let choices: Vec<ExtendedChoice> = {
144        let mut comp_tokens = 0usize;
145        let choices_out: Vec<ExtendedChoice> = raw_completions
146            .into_iter()
147            .enumerate()
148            .map(|(idx, raw_text)| {
149                let (truncated, hit_stop) = stop_checker.truncate_at_stop(&raw_text);
150                let finish_reason = "stop".to_string();
151                let _ = hit_stop;
152
153                // Apply JSON mode enforcement if requested
154                let final_text = if is_json_mode {
155                    json_enforcer.enforce(&truncated)
156                } else {
157                    truncated.clone()
158                };
159
160                // Check for tool call pattern in the output
161                let tool_calls = if tools.is_some() {
162                    let call_id = crate::api_types::generate_tool_call_id();
163                    crate::api_types::parse_tool_call(&final_text, &call_id).map(|tc| vec![tc])
164                } else {
165                    None
166                };
167
168                // Build logprobs (simplified: no actual logit data here, so we skip)
169                let logprobs = if want_logprobs && top_logprobs_k > 0 {
170                    // Without access to raw logits here, return empty content
171                    Some(ChoiceLogprobs {
172                        content: Some(vec![]),
173                    })
174                } else if want_logprobs {
175                    Some(ChoiceLogprobs {
176                        content: Some(vec![]),
177                    })
178                } else {
179                    None
180                };
181
182                // Estimate token count
183                let approx_tokens = final_text.split_whitespace().count().max(1);
184                comp_tokens += approx_tokens;
185
186                ExtendedChoice {
187                    index: idx,
188                    message: ChatMessage {
189                        role: "assistant".to_string(),
190                        content: Some(final_text),
191                        tool_calls: None,
192                        tool_call_id: None,
193                    },
194                    finish_reason,
195                    logprobs,
196                    tool_calls,
197                }
198            })
199            .collect();
200        total_completion_tokens = comp_tokens;
201        choices_out
202    };
203
204    // Build system fingerprint from model name
205    let system_fingerprint = Some(crate::api_types::fingerprint_from_config("bonsai-8b"));
206
207    let created = std::time::SystemTime::now()
208        .duration_since(std::time::UNIX_EPOCH)
209        .unwrap_or_default()
210        .as_secs();
211
212    let response = ExtendedChatResponse {
213        id: format!("chatcmpl-ext-{}", rand_ext_id()),
214        object: "chat.completion".to_string(),
215        created,
216        model: "bonsai-8b".to_string(),
217        choices,
218        usage: UsageInfo {
219            prompt_tokens: prompt_len,
220            completion_tokens: total_completion_tokens,
221            total_tokens: prompt_len + total_completion_tokens,
222        },
223        system_fingerprint,
224    };
225
226    Json(response).into_response()
227}
228
229/// Build a prompt string from a slice of chat messages (ChatML format).
230///
231/// Messages with `content = None` are skipped (they represent tool-call turns).
232fn build_extended_prompt(messages: &[ChatMessage]) -> String {
233    let mut prompt = String::new();
234    for msg in messages {
235        let text = match msg.content.as_deref() {
236            Some(t) => t,
237            None => continue,
238        };
239        match msg.role.as_str() {
240            "system" => {
241                prompt.push_str("<|im_start|>system\n");
242                prompt.push_str(text);
243                prompt.push_str("<|im_end|>\n");
244            }
245            "user" => {
246                prompt.push_str("<|im_start|>user\n");
247                prompt.push_str(text);
248                prompt.push_str("<|im_end|>\n");
249            }
250            "assistant" => {
251                prompt.push_str("<|im_start|>assistant\n");
252                prompt.push_str(text);
253                prompt.push_str("<|im_end|>\n");
254            }
255            _ => {
256                prompt.push_str(text);
257                prompt.push('\n');
258            }
259        }
260    }
261    prompt.push_str("<|im_start|>assistant\n");
262    prompt
263}
264
265fn rand_ext_id() -> String {
266    let ts = std::time::SystemTime::now()
267        .duration_since(std::time::UNIX_EPOCH)
268        .unwrap_or_default()
269        .as_nanos();
270    format!("{ts:x}")
271}
272
273// ── JSON mode enforcer ────────────────────────────────────────────────────────
274
275/// Wraps generation to produce valid JSON output.
276///
277/// Strategy (applied in order):
278/// 1. If the text already parses as JSON — return it as-is.
279/// 2. Try to extract the first `{…}` or `[…]` substring and parse that.
280/// 3. If still not valid JSON — wrap the text in `{"response": "<text>"}`.
281pub struct JsonModeEnforcer {
282    /// Maximum extraction/wrap attempts (unused here; reserved for future streaming use).
283    pub max_retries: usize,
284}
285
286impl JsonModeEnforcer {
287    /// Create a new enforcer with default settings.
288    pub fn new() -> Self {
289        Self { max_retries: 3 }
290    }
291
292    /// Return a string guaranteed to be valid JSON, applying extraction or
293    /// wrapping if needed.
294    pub fn enforce(&self, text: &str) -> String {
295        // Fast path: already valid JSON
296        if crate::api_types::is_valid_json(text) {
297            return text.to_string();
298        }
299
300        // Try to extract a JSON object substring
301        if let Some(extracted) = extract_json_substring(text) {
302            if crate::api_types::is_valid_json(&extracted) {
303                return extracted;
304            }
305        }
306
307        // Fallback: wrap in a JSON object
308        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
309        format!(r#"{{"response": "{escaped}"}}"#)
310    }
311}
312
313impl Default for JsonModeEnforcer {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319/// Try to find and return the first valid-looking JSON object or array in `text`.
320fn extract_json_substring(text: &str) -> Option<String> {
321    // Look for first `{` and last matching `}` (greedy — works for well-nested JSON)
322    if let Some(obj) = extract_balanced(text, '{', '}') {
323        return Some(obj);
324    }
325    // Try array
326    if let Some(arr) = extract_balanced(text, '[', ']') {
327        return Some(arr);
328    }
329    None
330}
331
332/// Extract the outermost balanced delimited substring starting from the first
333/// occurrence of `open` in `text`.
334fn extract_balanced(text: &str, open: char, close: char) -> Option<String> {
335    let start = text.find(open)?;
336    let substr = &text[start..];
337    let mut depth = 0i32;
338    let mut end_idx = None;
339
340    for (i, ch) in substr.char_indices() {
341        if ch == open {
342            depth += 1;
343        } else if ch == close {
344            depth -= 1;
345            if depth == 0 {
346                end_idx = Some(i + ch.len_utf8());
347                break;
348            }
349        }
350    }
351
352    end_idx.map(|e| substr[..e].to_string())
353}
354
355// ── Stop sequence checker ─────────────────────────────────────────────────────
356
357/// Detects and truncates text at stop sequences.
358pub struct StopChecker {
359    sequences: Vec<String>,
360}
361
362impl StopChecker {
363    /// Create a new checker with the given stop sequences.
364    pub fn new(sequences: Vec<String>) -> Self {
365        Self { sequences }
366    }
367
368    /// Returns `Some(&str)` with the first matched stop sequence, or `None`.
369    pub fn check<'a>(&'a self, text: &str) -> Option<&'a str> {
370        for seq in &self.sequences {
371            if text.contains(seq.as_str()) {
372                return Some(seq.as_str());
373            }
374        }
375        None
376    }
377
378    /// Return `(truncated_text, hit_stop)`.
379    ///
380    /// If any stop sequence is found, the text is truncated at that point.
381    pub fn truncate_at_stop(&self, text: &str) -> (String, bool) {
382        let mut earliest: Option<(usize, &str)> = None;
383        for seq in &self.sequences {
384            if let Some(pos) = text.find(seq.as_str()) {
385                match earliest {
386                    None => earliest = Some((pos, seq.as_str())),
387                    Some((prev_pos, _)) if pos < prev_pos => {
388                        earliest = Some((pos, seq.as_str()));
389                    }
390                    _ => {}
391                }
392            }
393        }
394
395        match earliest {
396            Some((pos, _)) => (text[..pos].to_string(), true),
397            None => (text.to_string(), false),
398        }
399    }
400
401    /// Returns `true` if no stop sequences are configured.
402    pub fn is_empty(&self) -> bool {
403        self.sequences.is_empty()
404    }
405}
406
407// ── Multi-completion generator ────────────────────────────────────────────────
408
409/// Generate `n` independent completions from the same prompt, seeding each run
410/// with `base_seed + i` for determinism.
411///
412/// **Note**: This function resets the engine before each run.
413pub fn generate_n_completions(
414    engine: &mut InferenceEngine<'_>,
415    prompt: &str,
416    params: &SamplingParams,
417    n: usize,
418    base_seed: u64,
419) -> Vec<String> {
420    let prompt_tokens: Vec<u32> = {
421        // Simple whitespace-based tokenization fallback (no real tokenizer available here)
422        prompt
423            .split_whitespace()
424            .enumerate()
425            .map(|(i, _)| (i as u32).wrapping_add(1000))
426            .collect()
427    };
428
429    let mut results = Vec::with_capacity(n);
430    for i in 0..n {
431        engine.reset();
432        let seed = base_seed.wrapping_add(i as u64);
433        let text = engine
434            .generate_with_seed(&prompt_tokens, 64, seed, params)
435            .map(|toks| format!("{toks:?}"))
436            .unwrap_or_else(|_| String::new());
437        results.push(text);
438    }
439    results
440}
441
442// ── Frequency / presence penalty ─────────────────────────────────────────────
443
444/// Apply frequency and presence penalties in-place to a logit vector.
445///
446/// For each token that has been seen:
447/// - **frequency penalty** reduces the logit proportionally to its count.
448/// - **presence penalty** reduces the logit by a fixed amount for any seen token.
449pub fn apply_frequency_penalty(
450    logits: &mut [f32],
451    token_counts: &HashMap<u32, usize>,
452    frequency_penalty: f32,
453    presence_penalty: f32,
454) {
455    for (&token_id, &count) in token_counts {
456        if let Some(logit) = logits.get_mut(token_id as usize) {
457            *logit -= frequency_penalty * count as f32;
458            *logit -= presence_penalty;
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn json_mode_enforcer_valid_passthrough() {
469        let enforcer = JsonModeEnforcer::new();
470        let json = r#"{"key": "value"}"#;
471        assert_eq!(enforcer.enforce(json), json);
472    }
473
474    #[test]
475    fn json_mode_enforcer_extracts_substring() {
476        let enforcer = JsonModeEnforcer::new();
477        let text = r#"Here is some text {"key": "value"} and more"#;
478        let result = enforcer.enforce(text);
479        assert!(
480            crate::api_types::is_valid_json(&result),
481            "result should be valid JSON, got: {result}"
482        );
483    }
484
485    #[test]
486    fn json_mode_enforcer_wraps_invalid() {
487        let enforcer = JsonModeEnforcer::new();
488        let text = "not json at all";
489        let result = enforcer.enforce(text);
490        assert!(
491            crate::api_types::is_valid_json(&result),
492            "result should be valid JSON, got: {result}"
493        );
494        let v: serde_json::Value = serde_json::from_str(&result).expect("should parse as json");
495        assert!(v.get("response").is_some(), "should have 'response' key");
496    }
497
498    #[test]
499    fn stop_checker_finds_sequence() {
500        let checker = StopChecker::new(vec!["STOP".to_string(), "END".to_string()]);
501        assert_eq!(checker.check("Hello STOP world"), Some("STOP"));
502        assert_eq!(checker.check("No match here"), None);
503    }
504
505    #[test]
506    fn stop_checker_truncates_correctly() {
507        let checker = StopChecker::new(vec!["<end>".to_string()]);
508        let (truncated, hit) = checker.truncate_at_stop("Hello world<end>more text");
509        assert_eq!(truncated, "Hello world");
510        assert!(hit);
511    }
512
513    #[test]
514    fn stop_checker_no_match() {
515        let checker = StopChecker::new(vec!["nope".to_string()]);
516        let (truncated, hit) = checker.truncate_at_stop("Hello world");
517        assert_eq!(truncated, "Hello world");
518        assert!(!hit);
519    }
520
521    #[test]
522    fn stop_checker_is_empty() {
523        let empty = StopChecker::new(vec![]);
524        assert!(empty.is_empty());
525        let non_empty = StopChecker::new(vec!["x".to_string()]);
526        assert!(!non_empty.is_empty());
527    }
528
529    #[test]
530    fn apply_frequency_penalty_reduces_seen() {
531        let mut logits = vec![1.0f32, 2.0, 3.0];
532        let mut counts = HashMap::new();
533        counts.insert(1u32, 2usize); // token 1 seen twice
534        apply_frequency_penalty(&mut logits, &counts, 0.5, 0.0);
535        // token 1 logit should be reduced by 0.5 * 2 = 1.0
536        assert!(
537            (logits[1] - 1.0).abs() < 1e-5,
538            "expected 1.0, got {}",
539            logits[1]
540        );
541        // others unchanged
542        assert!((logits[0] - 1.0).abs() < 1e-5);
543        assert!((logits[2] - 3.0).abs() < 1e-5);
544    }
545
546    #[test]
547    fn apply_presence_penalty_reduces_seen() {
548        let mut logits = vec![1.0f32, 2.0, 3.0];
549        let mut counts = HashMap::new();
550        counts.insert(0u32, 1usize);
551        apply_frequency_penalty(&mut logits, &counts, 0.0, 1.0);
552        assert!(
553            (logits[0] - 0.0).abs() < 1e-5,
554            "expected 0.0, got {}",
555            logits[0]
556        );
557        assert!((logits[1] - 2.0).abs() < 1e-5);
558    }
559
560    #[test]
561    fn extract_balanced_object() {
562        let text = r#"prefix {"a":1} suffix"#;
563        let result = extract_balanced(text, '{', '}');
564        assert_eq!(result.as_deref(), Some(r#"{"a":1}"#));
565    }
566
567    #[test]
568    fn extract_balanced_array() {
569        let text = r#"pre [1,2,3] post"#;
570        let result = extract_balanced(text, '[', ']');
571        assert_eq!(result.as_deref(), Some("[1,2,3]"));
572    }
573}