Skip to main content

ferrum_types/
requests.rs

1//! Request and response types for inference
2
3mod native_projection;
4mod xml_parameter_schema;
5mod xml_tool_calls;
6
7pub use native_projection::{NativeChatOutputProjection, NativeChatOutputProjector};
8
9use crate::{
10    ids::*, models::TokenUsage, FinishReason, Priority, ResponseCompletionEnvelope, SamplingParams,
11    TokenId,
12};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17pub const PROMPT_TOKENS_METADATA_KEY: &str = "ferrum_prompt_tokens";
18pub const DEFAULT_MAX_TOKENS_METADATA_KEY: &str = "ferrum_default_max_tokens";
19/// Actual rendered generation-suffix state, supplied by the prompt renderer.
20pub const PROMPT_OPENED_REASONING_METADATA_KEY: &str = "ferrum_prompt_opened_reasoning";
21
22/// Explicit request for execution evidence that is expensive or sensitive to retain.
23///
24/// The default keeps the inference hot path unchanged. Product entrypoints opt in
25/// only when the user enables a diagnostic artifact such as a request replay dump.
26#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
27pub struct InferenceEvidenceRequest {
28    #[serde(default)]
29    pub capture_prompt_token_ids: bool,
30    /// Capture engine token-commit timestamps from the request's monotonic
31    /// clock. Disabled by default so ordinary inference does not allocate or
32    /// read an additional clock in the token hot path.
33    #[serde(default)]
34    pub capture_engine_token_timing: bool,
35}
36
37/// Engine-boundary timing for one inference request.
38///
39/// Every token timestamp is an offset from the same `std::time::Instant`
40/// captured at request admission. The wall anchor exists only to correlate
41/// this monotonic domain with profile events; durations and ITL must be
42/// computed from `token_commit_nanos_since_request_start`, never by
43/// subtracting wall-clock timestamps.
44#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum EngineDecodeStage {
47    /// Host scheduling work that selected this request for a decode wave.
48    DecodeScheduling,
49    /// The model-executor call for one decode wave, including preparation,
50    /// device submission/completion, readback, and executor-side retirement.
51    DecodeExecution,
52    /// Host-side validation, sampling, and state commit after device execution.
53    DecodePostprocess,
54}
55
56/// A measured request-local engine decode stage in the request's monotonic
57/// clock domain. These are explicit producer boundaries, not intervals inferred
58/// by filling gaps between other profile events.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct EngineDecodeStageInterval {
61    pub stage: EngineDecodeStage,
62    pub start_nanos_since_request_start: u64,
63    pub end_nanos_since_request_start: u64,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct EngineTokenTimingEvidence {
68    pub clock_source: String,
69    pub wall_anchor_unix_nanos: i64,
70    pub wall_anchor_max_error_nanos: u64,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub decode_ready_nanos_since_request_start: Option<u64>,
73    pub token_commit_nanos_since_request_start: Vec<u64>,
74    /// Opt-in decode-stage evidence captured only with engine token timing.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub decode_stage_intervals: Vec<EngineDecodeStageInterval>,
77}
78
79impl EngineTokenTimingEvidence {
80    pub fn validate(&self, output_tokens: usize) -> Result<(), String> {
81        if self.clock_source != "rust_std_instant" {
82            return Err("engine token timing clock_source must be rust_std_instant".to_string());
83        }
84        if self.wall_anchor_unix_nanos <= 0 {
85            return Err("engine token timing wall anchor must be positive".to_string());
86        }
87        if self.token_commit_nanos_since_request_start.len() != output_tokens {
88            return Err(format!(
89                "engine token timing has {} commits for {output_tokens} output tokens",
90                self.token_commit_nanos_since_request_start.len()
91            ));
92        }
93        if self
94            .token_commit_nanos_since_request_start
95            .windows(2)
96            .any(|window| window[1] < window[0])
97        {
98            return Err("engine token commit timestamps must be monotonic".to_string());
99        }
100        if self.decode_stage_intervals.iter().any(|interval| {
101            interval.end_nanos_since_request_start < interval.start_nanos_since_request_start
102        }) {
103            return Err("engine decode stage interval end precedes start".to_string());
104        }
105        if self.decode_stage_intervals.windows(2).any(|window| {
106            window[1].start_nanos_since_request_start < window[0].start_nanos_since_request_start
107        }) {
108            return Err("engine decode stage intervals must be ordered by start".to_string());
109        }
110        Ok(())
111    }
112
113    pub fn ttft_nanos(&self) -> Option<u64> {
114        self.token_commit_nanos_since_request_start.first().copied()
115    }
116
117    pub fn inter_token_nanos(&self) -> Vec<u64> {
118        self.token_commit_nanos_since_request_start
119            .windows(2)
120            .map(|window| window[1].saturating_sub(window[0]))
121            .collect()
122    }
123
124    pub fn decode_wall_nanos(&self) -> Option<u64> {
125        let start = self.decode_ready_nanos_since_request_start?;
126        let end = self
127            .token_commit_nanos_since_request_start
128            .last()
129            .copied()?;
130        (end >= start).then_some(end - start)
131    }
132}
133
134/// Evidence captured at the engine execution boundary.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136pub struct InferenceExecutionEvidence {
137    #[serde(default)]
138    pub prompt_token_ids: Vec<TokenId>,
139    /// Complete generated-token history at the engine boundary. Streaming
140    /// chunks cannot reconstruct this reliably because special tokens and
141    /// deferred multi-byte pieces may not produce a visible text delta.
142    #[serde(default)]
143    pub output_token_ids: Vec<TokenId>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub engine_token_timing: Option<EngineTokenTimingEvidence>,
146}
147
148/// Inference request
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct InferenceRequest {
151    /// Unique request identifier
152    pub id: RequestId,
153    /// Input prompt text
154    pub prompt: String,
155    /// Model to use for inference
156    pub model_id: ModelId,
157    /// Sampling parameters
158    pub sampling_params: SamplingParams,
159    /// Whether to stream response
160    pub stream: bool,
161    /// Request priority
162    pub priority: Priority,
163    /// Client identifier
164    pub client_id: Option<ClientId>,
165    /// Session identifier for stateful interactions
166    pub session_id: Option<SessionId>,
167    /// Request creation timestamp
168    pub created_at: DateTime<Utc>,
169    /// Structured product/API request context. `prompt` remains the rendered
170    /// model input for current engines; this carries the original semantic
171    /// request boundary for API features such as tools and response formats.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub api_request: Option<ApiRequest>,
174    /// Explicitly requested execution evidence. Disabled by default so normal
175    /// inference does not retain or copy prompt token IDs after completion.
176    #[serde(default)]
177    pub evidence_request: InferenceEvidenceRequest,
178    /// Additional metadata
179    pub metadata: HashMap<String, serde_json::Value>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183#[serde(tag = "kind", rename_all = "snake_case")]
184pub enum ApiRequest {
185    Chat(ApiChatRequest),
186    Completion(ApiCompletionRequest),
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190#[serde(tag = "kind", rename_all = "snake_case")]
191pub enum ApiResponse {
192    Chat(ApiChatResponse),
193    Completion(ApiCompletionResponse),
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
197pub struct ApiChatRequest {
198    pub messages: Vec<ApiChatMessage>,
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub tools: Vec<ApiTool>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub tool_choice: Option<ApiToolChoice>,
203    /// Wire protocol emitted by the model's chat template for tool calls.
204    /// Native protocols retain their complete envelope for forced/named calls;
205    /// `Json` preserves the legacy bare-argument fallback.
206    #[serde(default)]
207    pub tool_call_protocol: ApiToolCallProtocol,
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub legacy_functions: Vec<ApiFunction>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub legacy_function_call: Option<ApiFunctionCallChoice>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub response_format: Option<ApiResponseFormat>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub stream_options: Option<ApiStreamOptions>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219pub struct ApiCompletionRequest {
220    pub prompt: String,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub response_format: Option<ApiResponseFormat>,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
226pub struct ApiChatResponse {
227    pub message: ApiChatMessage,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub finish_reason: Option<String>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233pub struct ApiCompletionResponse {
234    pub text: String,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub finish_reason: Option<String>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
240pub struct ApiChatMessage {
241    pub role: ApiMessageRole,
242    pub content: String,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub name: Option<String>,
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub tool_calls: Vec<ApiToolCall>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub tool_call_id: Option<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub function_call: Option<ApiFunctionCall>,
251}
252
253#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(rename_all = "lowercase")]
255pub enum ApiMessageRole {
256    System,
257    User,
258    Assistant,
259    Function,
260    Tool,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
264pub struct ApiTool {
265    #[serde(rename = "type")]
266    pub tool_type: String,
267    pub function: ApiFunction,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
271pub struct ApiFunction {
272    pub name: String,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub description: Option<String>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub parameters: Option<serde_json::Value>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub strict: Option<bool>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
282#[serde(untagged)]
283pub enum ApiToolChoice {
284    Mode(String),
285    Function {
286        #[serde(rename = "type")]
287        tool_type: String,
288        function: ApiToolChoiceFunction,
289    },
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
293pub struct ApiToolChoiceFunction {
294    pub name: String,
295}
296
297#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
298#[serde(rename_all = "snake_case")]
299pub enum ApiToolCallProtocol {
300    #[default]
301    Json,
302    /// A tools-aware template emits a named JSON call, not bare arguments.
303    NativeJson,
304    FunctionParameterXml,
305}
306
307impl ApiToolCallProtocol {
308    /// Atomic tokenizer entries that this wire protocol may need to emit.
309    ///
310    /// The sampling layer resolves these texts against the loaded tokenizer
311    /// and allows only exact matches. Other added-vocabulary tokens remain
312    /// masked.
313    pub const fn generated_control_token_texts(self) -> &'static [&'static str] {
314        match self {
315            Self::Json | Self::NativeJson => &[],
316            Self::FunctionParameterXml => &["<tool_call>", "</tool_call>"],
317        }
318    }
319
320    /// Lexical envelope that may satisfy a pending response boundary.
321    pub fn generated_response_envelope(self) -> Option<ResponseCompletionEnvelope> {
322        match self {
323            Self::Json | Self::NativeJson => None,
324            Self::FunctionParameterXml => Some(ResponseCompletionEnvelope {
325                open_token_text: "<tool_call>".to_string(),
326                close_token_text: "</tool_call>".to_string(),
327                max_envelopes: MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE,
328            }),
329        }
330    }
331}
332
333impl ApiChatRequest {
334    /// A forced native call must retain its declared envelope and tool name.
335    /// A bare argument-object grammar would mask the model's native wire format.
336    pub fn requires_native_tool_call(&self) -> bool {
337        matches!(
338            self.tool_call_protocol,
339            ApiToolCallProtocol::NativeJson | ApiToolCallProtocol::FunctionParameterXml
340        ) && match self.tool_choice.as_ref() {
341            Some(ApiToolChoice::Mode(mode)) => mode.eq_ignore_ascii_case("required"),
342            Some(ApiToolChoice::Function { tool_type, .. }) => tool_type == "function",
343            None => false,
344        }
345    }
346
347    pub fn allows_tool_name(&self, name: &str) -> bool {
348        api_tool_name_allowed(self, name)
349    }
350
351    /// Automatic tool calls and hard final JSON are alternative output branches.
352    /// A final object must never acquire tool-call semantics merely because its
353    /// fields happen to match a function's argument schema.
354    pub fn automatic_tools_with_hard_response_format(&self) -> bool {
355        !self.tools.is_empty()
356            && self.tool_choice.as_ref().is_none_or(|choice| {
357                matches!(choice, ApiToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("auto"))
358            })
359            && self.response_format.as_ref().is_some_and(|format| {
360                format.format_type == "json_object"
361                    || (format.format_type == "json_schema"
362                        && format
363                            .json_schema
364                            .as_ref()
365                            .is_some_and(|schema| schema.strict == Some(true)))
366            })
367    }
368
369    /// Protocol controls are generatable only when this request can emit a
370    /// modern tool call. `tool_choice: none` must not widen token sampling.
371    pub fn generated_control_token_texts(&self) -> &'static [&'static str] {
372        if self.tools.is_empty() || api_tool_choice_is_none(self) {
373            return &[];
374        }
375        if self.automatic_tools_with_hard_response_format() || self.requires_native_tool_call() {
376            return &["<tool_call>", "</tool_call>"];
377        }
378        self.tool_call_protocol.generated_control_token_texts()
379    }
380
381    /// Alternate complete response envelope available to this request.
382    pub fn generated_response_envelope(&self) -> Option<ResponseCompletionEnvelope> {
383        if self.tools.is_empty() || api_tool_choice_is_none(self) {
384            return None;
385        }
386        if self.automatic_tools_with_hard_response_format() || self.requires_native_tool_call() {
387            return Some(ResponseCompletionEnvelope {
388                open_token_text: "<tool_call>".to_string(),
389                close_token_text: "</tool_call>".to_string(),
390                max_envelopes: MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE,
391            });
392        }
393        self.tool_call_protocol.generated_response_envelope()
394    }
395}
396
397impl ApiRequest {
398    pub fn generated_control_token_texts(&self) -> &'static [&'static str] {
399        match self {
400            Self::Chat(request) => request.generated_control_token_texts(),
401            Self::Completion(_) => &[],
402        }
403    }
404
405    pub fn generated_response_envelope(&self) -> Option<ResponseCompletionEnvelope> {
406        match self {
407            Self::Chat(request) => request.generated_response_envelope(),
408            Self::Completion(_) => None,
409        }
410    }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
414#[serde(untagged)]
415pub enum ApiFunctionCallChoice {
416    Mode(String),
417    Function { name: String },
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421pub struct ApiToolCall {
422    pub id: String,
423    #[serde(rename = "type")]
424    pub tool_type: String,
425    pub function: ApiFunctionCall,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
429pub struct ApiFunctionCall {
430    pub name: String,
431    pub arguments: String,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
435pub struct ApiResponseFormat {
436    #[serde(rename = "type")]
437    pub format_type: String,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub json_schema: Option<ApiJsonSchema>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
443pub struct ApiJsonSchema {
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub name: Option<String>,
446    pub schema: serde_json::Value,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub strict: Option<bool>,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
452pub struct ApiStreamOptions {
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub include_usage: Option<bool>,
455}
456
457const MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE: usize = 32;
458
459pub fn api_response_from_generated_text(
460    request: &InferenceRequest,
461    text: &str,
462    finish_reason: FinishReason,
463) -> Option<ApiResponse> {
464    if let Some(mut projector) = NativeChatOutputProjector::for_request(request) {
465        projector.push(text);
466        return projector
467            .finish(finish_reason)
468            .api_response
469            .map(ApiResponse::Chat);
470    }
471    let ApiRequest::Chat(chat_request) = request.api_request.as_ref()? else {
472        return None;
473    };
474    chat_api_response_from_generated_text(chat_request, text, finish_reason).map(ApiResponse::Chat)
475}
476
477/// Branch established by the structured-output grammar for a complete result.
478/// When both the final and tool languages accept the same bytes, the grammar
479/// owner must select `Final` before invoking the classified response helper.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub enum StructuredOutputBranch {
482    Final,
483    ToolCall,
484}
485
486/// Preserve an authoritative grammar decision through product response shaping.
487///
488/// `text` is the complete result payload, excluding reasoning and its headers.
489/// This function does not validate the final schema or infer branch ownership
490/// from JSON fields. Only a caller that established full grammar acceptance
491/// and final precedence may supply `branch`.
492///
493/// A final answer deliberately returns `Some`, even when its fields resemble a
494/// call, so downstream text fallbacks cannot reinterpret it. Classified calls
495/// accept complete registered envelopes or a named JSON object with explicit
496/// arguments; they never infer the sole tool from an unnamed argument object.
497pub fn api_response_from_classified_generated_text(
498    request: &InferenceRequest,
499    text: &str,
500    finish_reason: FinishReason,
501    branch: StructuredOutputBranch,
502) -> crate::Result<Option<ApiResponse>> {
503    let Some(ApiRequest::Chat(chat_request)) = request.api_request.as_ref() else {
504        return Ok(None);
505    };
506    if !matches!(finish_reason, FinishReason::Stop | FinishReason::EOS) {
507        return Ok(None);
508    }
509    if !chat_request.automatic_tools_with_hard_response_format()
510        && !chat_request.requires_native_tool_call()
511    {
512        return Err(crate::FerrumError::invalid_request(
513            "classified structured output requires a composed tool output contract",
514        ));
515    }
516    if chat_request.requires_native_tool_call() && branch == StructuredOutputBranch::Final {
517        return Err(crate::FerrumError::invalid_request(
518            "a required native tool call cannot be classified as a final answer",
519        ));
520    }
521
522    let (content, tool_calls, wire_finish_reason) = match branch {
523        StructuredOutputBranch::Final => (text.to_string(), Vec::new(), "stop"),
524        StructuredOutputBranch::ToolCall => {
525            let calls = parse_explicit_tool_call_envelopes(text, chat_request)
526                .or_else(|| parse_classified_named_json_call(text, chat_request))
527                .filter(|calls| {
528                    calls.iter().all(|call| {
529                        serde_json::from_str::<serde_json::Value>(&call.function.arguments)
530                            .is_ok_and(|arguments| arguments.is_object())
531                    })
532                })
533                .ok_or_else(|| {
534                    crate::FerrumError::invalid_format(
535                        "classified tool output is not a complete declared function call",
536                    )
537                })?;
538            (String::new(), calls, "tool_calls")
539        }
540    };
541    Ok(Some(ApiResponse::Chat(ApiChatResponse {
542        message: ApiChatMessage {
543            role: ApiMessageRole::Assistant,
544            content,
545            name: None,
546            tool_calls,
547            tool_call_id: None,
548            function_call: None,
549        },
550        finish_reason: Some(wire_finish_reason.to_string()),
551    })))
552}
553
554fn parse_classified_named_json_call(
555    text: &str,
556    chat_request: &ApiChatRequest,
557) -> Option<Vec<ApiToolCall>> {
558    if !matches!(
559        chat_request.tool_call_protocol,
560        ApiToolCallProtocol::Json | ApiToolCallProtocol::NativeJson
561    ) {
562        return None;
563    }
564    // Consume the whole JSON value, without searching surrounding prose,
565    // fences, native control markers, or a reasoning section for a call.
566    let value: serde_json::Value = serde_json::from_str(text).ok()?;
567    let name = value.get("name")?.as_str()?;
568    if !api_tool_name_allowed(chat_request, name) {
569        return None;
570    }
571    let arguments = match (value.get("arguments"), value.get("parameters")) {
572        (Some(arguments), None) | (None, Some(arguments)) => arguments,
573        _ => return None,
574    };
575    if !arguments.is_object() {
576        return None;
577    }
578    let call = ApiToolCall {
579        id: "call_0".to_string(),
580        tool_type: "function".to_string(),
581        function: ApiFunctionCall {
582            name: name.to_string(),
583            arguments: serde_json::to_string(arguments).ok()?,
584        },
585    };
586    validate_parsed_tool_calls(vec![call])
587}
588
589pub fn chat_api_may_emit_tool_or_function_call(chat_request: &ApiChatRequest) -> bool {
590    (!chat_request.tools.is_empty() && !api_tool_choice_is_none(chat_request))
591        || (!chat_request.legacy_functions.is_empty()
592            && !api_function_call_choice_is_none(chat_request))
593}
594
595pub fn chat_api_response_from_generated_text(
596    chat_request: &ApiChatRequest,
597    text: &str,
598    finish_reason: FinishReason,
599) -> Option<ApiChatResponse> {
600    if !matches!(finish_reason, FinishReason::Stop | FinishReason::EOS) {
601        return None;
602    }
603
604    if !chat_request.tools.is_empty() && !api_tool_choice_is_none(chat_request) {
605        if let Some((content, tool_calls)) =
606            parse_tool_calls_from_generated_text(text, chat_request)
607        {
608            return Some(ApiChatResponse {
609                message: ApiChatMessage {
610                    role: ApiMessageRole::Assistant,
611                    content,
612                    name: None,
613                    tool_calls,
614                    tool_call_id: None,
615                    function_call: None,
616                },
617                finish_reason: Some("tool_calls".to_string()),
618            });
619        }
620    }
621
622    if !chat_request.legacy_functions.is_empty() && !api_function_call_choice_is_none(chat_request)
623    {
624        if let Some(function_call) =
625            parse_legacy_function_call_from_generated_text(text, chat_request)
626        {
627            return Some(ApiChatResponse {
628                message: ApiChatMessage {
629                    role: ApiMessageRole::Assistant,
630                    content: String::new(),
631                    name: None,
632                    tool_calls: Vec::new(),
633                    tool_call_id: None,
634                    function_call: Some(function_call),
635                },
636                finish_reason: Some("function_call".to_string()),
637            });
638        }
639    }
640
641    None
642}
643
644fn api_tool_choice_is_none(chat_request: &ApiChatRequest) -> bool {
645    matches!(
646        chat_request.tool_choice.as_ref(),
647        Some(ApiToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none")
648    )
649}
650
651fn api_function_call_choice_is_none(chat_request: &ApiChatRequest) -> bool {
652    matches!(
653        chat_request.legacy_function_call.as_ref(),
654        Some(ApiFunctionCallChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none")
655    )
656}
657
658fn parse_tool_calls_from_generated_text(
659    text: &str,
660    chat_request: &ApiChatRequest,
661) -> Option<(String, Vec<ApiToolCall>)> {
662    if chat_request.automatic_tools_with_hard_response_format()
663        || chat_request.requires_native_tool_call()
664    {
665        return parse_explicit_tool_call_envelopes(text, chat_request)
666            .or_else(|| {
667                // Only a forced native JSON contract makes a complete named
668                // object unambiguously a call. Automatic hard final JSON still
669                // requires the sampler's branch classification.
670                chat_request
671                    .requires_native_tool_call()
672                    .then(|| parse_classified_named_json_call(text, chat_request))
673                    .flatten()
674            })
675            .map(|calls| (String::new(), calls));
676    }
677    if chat_request.tool_call_protocol == ApiToolCallProtocol::FunctionParameterXml {
678        // Only native framing establishes tool intent for this protocol.
679        // Ordinary JSON or JSON inside malformed framing is not a fallback.
680        return xml_tool_calls::parse_with_content(text, chat_request, false)
681            .map(|parsed| (parsed.content, parsed.calls));
682    }
683
684    let value = parse_json_value_from_generated_text(text)?;
685    parse_json_tool_call_value(&value, chat_request, 0, true).map(|calls| (String::new(), calls))
686}
687
688fn parse_json_tool_call_value(
689    value: &serde_json::Value,
690    chat_request: &ApiChatRequest,
691    index_offset: usize,
692    allow_unwrapped_arguments: bool,
693) -> Option<Vec<ApiToolCall>> {
694    if let Some(calls) = value.get("tool_calls").and_then(|value| value.as_array()) {
695        if calls.len() > MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE {
696            return None;
697        }
698        let parsed = calls
699            .iter()
700            .enumerate()
701            .map(|(index, value)| parse_tool_call_value(value, index_offset + index, chat_request))
702            .collect::<Option<Vec<_>>>()?;
703        return validate_parsed_tool_calls(parsed);
704    }
705    if let Some(tool_call) = value.get("tool_call") {
706        return parse_tool_call_value(tool_call, index_offset, chat_request)
707            .and_then(|call| validate_parsed_tool_calls(vec![call]));
708    }
709    if let Some(tool_call) = parse_wrapped_tool_call_value(value, index_offset, chat_request) {
710        return validate_parsed_tool_calls(vec![tool_call]);
711    }
712    parse_tool_call_value(value, index_offset, chat_request)
713        .or_else(|| {
714            allow_unwrapped_arguments
715                .then(|| parse_forced_tool_arguments_value(value, index_offset, chat_request))
716                .flatten()
717        })
718        .and_then(|call| validate_parsed_tool_calls(vec![call]))
719}
720
721/// Only explicit, fully consumed protocol envelopes establish a tool branch.
722/// Do not search arbitrary text for tags: they may be literal final JSON data.
723fn parse_explicit_tool_call_envelopes(
724    text: &str,
725    chat_request: &ApiChatRequest,
726) -> Option<Vec<ApiToolCall>> {
727    if chat_request.tool_call_protocol == ApiToolCallProtocol::FunctionParameterXml {
728        return xml_tool_calls::parse(text, chat_request, true);
729    }
730    const OPEN: &str = "<tool_call>";
731    const CLOSE: &str = "</tool_call>";
732    let mut remaining = text.trim();
733    let mut calls = Vec::new();
734    while !remaining.is_empty() {
735        if calls.len() >= MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE {
736            return None;
737        }
738        let payload = remaining.strip_prefix(OPEN)?.trim_start();
739        // The closing marker can legitimately occur inside a JSON
740        // argument string; consume JSON before interpreting framing.
741        let mut values =
742            serde_json::Deserializer::from_str(payload).into_iter::<serde_json::Value>();
743        let value = values.next()?.ok()?;
744        remaining = payload[values.byte_offset()..]
745            .trim_start()
746            .strip_prefix(CLOSE)?
747            .trim_start();
748        let parsed = parse_json_tool_call_value(&value, chat_request, calls.len(), false)?;
749        if calls.len() + parsed.len() > MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE {
750            return None;
751        }
752        for call in parsed {
753            if calls
754                .iter()
755                .any(|previous: &ApiToolCall| previous.id == call.id)
756            {
757                return None;
758            }
759            calls.push(call);
760        }
761    }
762    validate_parsed_tool_calls(calls)
763}
764
765fn validate_parsed_tool_calls(calls: Vec<ApiToolCall>) -> Option<Vec<ApiToolCall>> {
766    if calls.is_empty() || calls.len() > MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE {
767        return None;
768    }
769    for (index, call) in calls.iter().enumerate() {
770        if calls[..index].iter().any(|previous| {
771            previous.function.name == call.function.name
772                && previous.function.arguments == call.function.arguments
773        }) {
774            return None;
775        }
776    }
777    Some(calls)
778}
779
780fn parse_wrapped_tool_call_value(
781    value: &serde_json::Value,
782    index: usize,
783    chat_request: &ApiChatRequest,
784) -> Option<ApiToolCall> {
785    for key in ["auto", "tool", "tool_call", "auto_tool_response"] {
786        if let Some(wrapped) = value.get(key) {
787            if let Some(call) = parse_tool_call_value(wrapped, index, chat_request) {
788                return Some(call);
789            }
790        }
791    }
792    None
793}
794
795fn parse_tool_call_value(
796    value: &serde_json::Value,
797    index: usize,
798    chat_request: &ApiChatRequest,
799) -> Option<ApiToolCall> {
800    let tool_type = value
801        .get("type")
802        .and_then(|value| value.as_str())
803        .unwrap_or("function");
804    if tool_type != "function" {
805        return None;
806    }
807    let function = value.get("function").unwrap_or(value);
808    let name = function
809        .as_str()
810        .or_else(|| function.get("name").and_then(|value| value.as_str()))
811        .or_else(|| function.get("tool").and_then(|value| value.as_str()))
812        .or_else(|| value.get("name").and_then(|value| value.as_str()))?;
813    if !api_tool_name_allowed(chat_request, name) {
814        return None;
815    }
816    let arguments = api_arguments_to_string(
817        function
818            .get("arguments")
819            .or_else(|| function.get("parameters"))
820            .or_else(|| value.get("arguments"))
821            .or_else(|| value.get("parameters")),
822    );
823    let id = value
824        .get("id")
825        .and_then(|value| value.as_str())
826        .map(str::to_string)
827        .unwrap_or_else(|| format!("call_{index}"));
828
829    Some(ApiToolCall {
830        id,
831        tool_type: "function".to_string(),
832        function: ApiFunctionCall {
833            name: name.to_string(),
834            arguments,
835        },
836    })
837}
838
839fn parse_forced_tool_arguments_value(
840    value: &serde_json::Value,
841    index: usize,
842    chat_request: &ApiChatRequest,
843) -> Option<ApiToolCall> {
844    let tool = unwrapped_tool_arguments_target(chat_request, value)?;
845    if value.get("tool_calls").is_some()
846        || value.get("tool_call").is_some()
847        || value.get("function").is_some()
848        || value.get("name").is_some()
849    {
850        return None;
851    }
852
853    Some(ApiToolCall {
854        id: format!("call_{index}"),
855        tool_type: "function".to_string(),
856        function: ApiFunctionCall {
857            name: tool.function.name.clone(),
858            arguments: serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
859        },
860    })
861}
862
863fn unwrapped_tool_arguments_target<'a>(
864    chat_request: &'a ApiChatRequest,
865    value: &serde_json::Value,
866) -> Option<&'a ApiTool> {
867    if let Some(name) = forced_tool_choice_name(chat_request) {
868        return chat_request
869            .tools
870            .iter()
871            .find(|tool| tool.tool_type == "function" && tool.function.name == name);
872    }
873
874    if matches!(
875        chat_request.tool_choice.as_ref(),
876        Some(ApiToolChoice::Mode(mode)) if !mode.eq_ignore_ascii_case("auto")
877    ) {
878        return None;
879    }
880
881    let tool = single_function_tool(chat_request)?;
882    if !value_looks_like_tool_arguments(value, tool) {
883        return None;
884    }
885    Some(tool)
886}
887
888fn value_looks_like_tool_arguments(value: &serde_json::Value, tool: &ApiTool) -> bool {
889    let Some(arguments) = value.as_object() else {
890        return false;
891    };
892    if arguments.is_empty() {
893        return false;
894    }
895    let Some(properties) = tool
896        .function
897        .parameters
898        .as_ref()
899        .and_then(|parameters| parameters.get("properties"))
900        .and_then(|properties| properties.as_object())
901    else {
902        return false;
903    };
904    arguments.keys().all(|key| properties.contains_key(key))
905}
906
907fn forced_tool_choice_name(chat_request: &ApiChatRequest) -> Option<&str> {
908    match chat_request.tool_choice.as_ref() {
909        Some(ApiToolChoice::Function {
910            tool_type,
911            function,
912        }) if tool_type == "function" && api_tool_name_allowed(chat_request, &function.name) => {
913            Some(function.name.as_str())
914        }
915        Some(ApiToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => {
916            single_function_tool(chat_request).map(|tool| tool.function.name.as_str())
917        }
918        _ => None,
919    }
920}
921
922fn single_function_tool(chat_request: &ApiChatRequest) -> Option<&ApiTool> {
923    let mut tools = chat_request
924        .tools
925        .iter()
926        .filter(|tool| tool.tool_type == "function");
927    let tool = tools.next()?;
928    tools.next().is_none().then_some(tool)
929}
930
931fn parse_legacy_function_call_from_generated_text(
932    text: &str,
933    chat_request: &ApiChatRequest,
934) -> Option<ApiFunctionCall> {
935    let value = parse_json_value_from_generated_text(text)?;
936    let function = value.get("function_call").unwrap_or(&value);
937    let name = function.get("name").and_then(|value| value.as_str())?;
938    if !api_function_name_allowed(chat_request, name) {
939        return None;
940    }
941    Some(ApiFunctionCall {
942        name: name.to_string(),
943        arguments: api_arguments_to_string(function.get("arguments")),
944    })
945}
946
947fn api_tool_name_allowed(chat_request: &ApiChatRequest, name: &str) -> bool {
948    match chat_request.tool_choice.as_ref() {
949        Some(ApiToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none") => false,
950        Some(ApiToolChoice::Function {
951            tool_type,
952            function,
953        }) => {
954            tool_type == "function"
955                && function.name == name
956                && chat_request
957                    .tools
958                    .iter()
959                    .any(|tool| tool.function.name == name)
960        }
961        _ => chat_request
962            .tools
963            .iter()
964            .any(|tool| tool.function.name == name),
965    }
966}
967
968fn api_function_name_allowed(chat_request: &ApiChatRequest, name: &str) -> bool {
969    match chat_request.legacy_function_call.as_ref() {
970        Some(ApiFunctionCallChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none") => false,
971        Some(ApiFunctionCallChoice::Function { name: selected }) => {
972            selected == name
973                && chat_request
974                    .legacy_functions
975                    .iter()
976                    .any(|function| function.name == name)
977        }
978        _ => chat_request
979            .legacy_functions
980            .iter()
981            .any(|function| function.name == name),
982    }
983}
984
985fn parse_json_value_from_generated_text(text: &str) -> Option<serde_json::Value> {
986    let trimmed = strip_single_json_fence(text.trim());
987    serde_json::from_str(trimmed).ok().or_else(|| {
988        let start = trimmed.find('{')?;
989        let end = trimmed.rfind('}')?;
990        (start <= end)
991            .then(|| serde_json::from_str(&trimmed[start..=end]).ok())
992            .flatten()
993    })
994}
995
996fn strip_single_json_fence(text: &str) -> &str {
997    let Some(rest) = text.strip_prefix("```") else {
998        return text;
999    };
1000    let rest = rest.strip_prefix("json").unwrap_or(rest).trim_start();
1001    rest.strip_suffix("```").map(str::trim).unwrap_or(text)
1002}
1003
1004fn api_arguments_to_string(arguments: Option<&serde_json::Value>) -> String {
1005    match arguments {
1006        Some(serde_json::Value::String(raw)) => raw.clone(),
1007        Some(value) => serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
1008        None => "{}".to_string(),
1009    }
1010}
1011
1012impl InferenceRequest {
1013    /// Whether sampling must compile a grammar, including native tool-only output.
1014    pub fn requires_structured_output(&self) -> bool {
1015        !matches!(
1016            self.sampling_params.response_format,
1017            crate::ResponseFormat::Text
1018        ) || matches!(self.api_request.as_ref(), Some(ApiRequest::Chat(chat)) if chat.requires_native_tool_call())
1019    }
1020
1021    /// Create a new inference request
1022    pub fn new(prompt: impl Into<String>, model_id: impl Into<ModelId>) -> Self {
1023        Self {
1024            id: RequestId::new(),
1025            prompt: prompt.into(),
1026            model_id: model_id.into(),
1027            sampling_params: SamplingParams::default(),
1028            stream: false,
1029            priority: Priority::default(),
1030            client_id: None,
1031            session_id: None,
1032            created_at: Utc::now(),
1033            api_request: None,
1034            evidence_request: InferenceEvidenceRequest::default(),
1035            metadata: HashMap::new(),
1036        }
1037    }
1038
1039    /// Set sampling parameters
1040    pub fn with_sampling_params(mut self, params: SamplingParams) -> Self {
1041        self.sampling_params = params;
1042        self
1043    }
1044
1045    /// Enable streaming
1046    pub fn with_stream(mut self, stream: bool) -> Self {
1047        self.stream = stream;
1048        self
1049    }
1050
1051    /// Set priority
1052    pub fn with_priority(mut self, priority: Priority) -> Self {
1053        self.priority = priority;
1054        self
1055    }
1056
1057    /// Set client ID
1058    pub fn with_client_id(mut self, client_id: impl Into<ClientId>) -> Self {
1059        self.client_id = Some(client_id.into());
1060        self
1061    }
1062
1063    /// Set session ID
1064    pub fn with_session_id(mut self, session_id: SessionId) -> Self {
1065        self.session_id = Some(session_id);
1066        self
1067    }
1068
1069    /// Set structured product/API request context.
1070    pub fn with_api_request(mut self, api_request: ApiRequest) -> Self {
1071        self.api_request = Some(api_request);
1072        self
1073    }
1074
1075    /// Request prompt-token evidence from the execution boundary.
1076    pub fn with_prompt_token_evidence(mut self) -> Self {
1077        self.evidence_request.capture_prompt_token_ids = true;
1078        self
1079    }
1080
1081    /// Request engine token-commit timing evidence.
1082    pub fn with_engine_token_timing_evidence(mut self) -> Self {
1083        self.evidence_request.capture_engine_token_timing = true;
1084        self
1085    }
1086
1087    /// Add metadata
1088    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
1089        self.metadata.insert(key.into(), value);
1090        self
1091    }
1092}
1093
1094/// Inference response
1095#[derive(Debug, Clone, Serialize, Deserialize)]
1096pub struct InferenceResponse {
1097    /// Request ID this response corresponds to
1098    pub request_id: RequestId,
1099    /// Generated text
1100    pub text: String,
1101    /// Generated token IDs
1102    pub tokens: Vec<TokenId>,
1103    /// Reason for completion
1104    pub finish_reason: FinishReason,
1105    /// Token usage statistics
1106    pub usage: TokenUsage,
1107    /// Total latency in milliseconds
1108    pub latency_ms: u64,
1109    /// Response creation timestamp
1110    pub created_at: DateTime<Utc>,
1111    /// Additional response metadata
1112    pub metadata: HashMap<String, serde_json::Value>,
1113    /// Structured product/API response context. Engines that can produce
1114    /// product-native outputs, such as assistant tool calls, can populate
1115    /// this without overloading plain text or ad hoc metadata.
1116    #[serde(default, skip_serializing_if = "Option::is_none")]
1117    pub api_response: Option<ApiResponse>,
1118    /// Optional engine-boundary evidence requested by the caller.
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub execution_evidence: Option<InferenceExecutionEvidence>,
1121}
1122
1123/// Streaming response chunk
1124#[derive(Debug, Clone, Serialize, Deserialize)]
1125pub struct StreamChunk {
1126    /// Request ID this chunk corresponds to
1127    pub request_id: RequestId,
1128    /// Text delta for this chunk
1129    pub text: String,
1130    /// Token ID for this chunk (if available)
1131    pub token: Option<TokenId>,
1132    /// Finish reason if this is the final chunk
1133    pub finish_reason: Option<FinishReason>,
1134    /// Token usage (typically only in final chunk)
1135    pub usage: Option<TokenUsage>,
1136    /// Chunk creation timestamp
1137    pub created_at: DateTime<Utc>,
1138    /// Chunk metadata
1139    pub metadata: HashMap<String, serde_json::Value>,
1140    /// Structured product/API response context for final streaming chunks.
1141    /// This mirrors `InferenceResponse::api_response` so streaming endpoints
1142    /// can return native tool/function-call payloads without reparsing text.
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub api_response: Option<ApiResponse>,
1145    /// Optional engine-boundary evidence, emitted on the final chunk.
1146    #[serde(default, skip_serializing_if = "Option::is_none")]
1147    pub execution_evidence: Option<InferenceExecutionEvidence>,
1148}
1149
1150/// Batch request for processing multiple requests together
1151#[derive(Debug, Clone, Serialize, Deserialize)]
1152pub struct BatchRequest {
1153    /// Batch identifier
1154    pub batch_id: BatchId,
1155    /// Requests in this batch
1156    pub requests: Vec<InferenceRequest>,
1157    /// Maximum sequence length for this batch
1158    pub max_sequence_length: usize,
1159    /// Batch creation timestamp
1160    pub created_at: DateTime<Utc>,
1161}
1162
1163impl BatchRequest {
1164    /// Create a new batch request
1165    pub fn new(requests: Vec<InferenceRequest>) -> Self {
1166        let max_sequence_length = requests
1167            .iter()
1168            .map(|r| r.sampling_params.max_tokens)
1169            .max()
1170            .unwrap_or(512);
1171
1172        Self {
1173            batch_id: BatchId::new(),
1174            requests,
1175            max_sequence_length,
1176            created_at: Utc::now(),
1177        }
1178    }
1179
1180    /// Get the number of requests in this batch
1181    pub fn size(&self) -> usize {
1182        self.requests.len()
1183    }
1184
1185    /// Check if batch is empty
1186    pub fn is_empty(&self) -> bool {
1187        self.requests.is_empty()
1188    }
1189}
1190
1191/// Request state in the scheduler
1192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1193pub enum RequestState {
1194    /// Request is waiting in queue
1195    Waiting,
1196    /// Request is being processed
1197    Running,
1198    /// Request was preempted and is waiting to resume
1199    Preempted,
1200    /// Request completed successfully
1201    Completed,
1202    /// Request failed with error
1203    Failed,
1204    /// Request was cancelled
1205    Cancelled,
1206}
1207
1208/// Scheduled request with additional state information
1209#[derive(Debug, Clone)]
1210pub struct ScheduledRequest {
1211    /// The original request
1212    pub request: InferenceRequest,
1213    /// Current state in scheduler
1214    pub state: RequestState,
1215    /// Allocated cache blocks
1216    pub allocated_blocks: Vec<crate::BlockId>,
1217    /// Number of tokens processed so far
1218    pub tokens_processed: usize,
1219    /// Estimated completion time
1220    pub estimated_completion: Option<DateTime<Utc>>,
1221}
1222
1223impl ScheduledRequest {
1224    /// Create a new scheduled request
1225    pub fn new(request: InferenceRequest) -> Self {
1226        Self {
1227            request,
1228            state: RequestState::Waiting,
1229            allocated_blocks: Vec::new(),
1230            tokens_processed: 0,
1231            estimated_completion: None,
1232        }
1233    }
1234
1235    /// Update request state
1236    pub fn set_state(&mut self, state: RequestState) {
1237        self.state = state;
1238    }
1239
1240    /// Add allocated cache blocks
1241    pub fn add_blocks(&mut self, blocks: Vec<crate::BlockId>) {
1242        self.allocated_blocks.extend(blocks);
1243    }
1244
1245    /// Update tokens processed
1246    pub fn update_progress(&mut self, tokens_processed: usize) {
1247        self.tokens_processed = tokens_processed;
1248    }
1249}