Skip to main content

ferrum_types/
requests.rs

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