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    /// Protocol controls are generatable only when this request can emit a
327    /// modern tool call. `tool_choice: none` must not widen token sampling.
328    pub fn generated_control_token_texts(&self) -> &'static [&'static str] {
329        if self.tools.is_empty() || api_tool_choice_is_none(self) {
330            return &[];
331        }
332        self.tool_call_protocol.generated_control_token_texts()
333    }
334
335    /// Alternate complete response envelope available to this request.
336    pub fn generated_response_envelope(&self) -> Option<ResponseCompletionEnvelope> {
337        if self.tools.is_empty() || api_tool_choice_is_none(self) {
338            return None;
339        }
340        self.tool_call_protocol.generated_response_envelope()
341    }
342}
343
344impl ApiRequest {
345    pub fn generated_control_token_texts(&self) -> &'static [&'static str] {
346        match self {
347            Self::Chat(request) => request.generated_control_token_texts(),
348            Self::Completion(_) => &[],
349        }
350    }
351
352    pub fn generated_response_envelope(&self) -> Option<ResponseCompletionEnvelope> {
353        match self {
354            Self::Chat(request) => request.generated_response_envelope(),
355            Self::Completion(_) => None,
356        }
357    }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
361#[serde(untagged)]
362pub enum ApiFunctionCallChoice {
363    Mode(String),
364    Function { name: String },
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
368pub struct ApiToolCall {
369    pub id: String,
370    #[serde(rename = "type")]
371    pub tool_type: String,
372    pub function: ApiFunctionCall,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
376pub struct ApiFunctionCall {
377    pub name: String,
378    pub arguments: String,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
382pub struct ApiResponseFormat {
383    #[serde(rename = "type")]
384    pub format_type: String,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub json_schema: Option<ApiJsonSchema>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
390pub struct ApiJsonSchema {
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub name: Option<String>,
393    pub schema: serde_json::Value,
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub strict: Option<bool>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
399pub struct ApiStreamOptions {
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub include_usage: Option<bool>,
402}
403
404const MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE: usize = 32;
405
406pub fn api_response_from_generated_text(
407    request: &InferenceRequest,
408    text: &str,
409    finish_reason: FinishReason,
410) -> Option<ApiResponse> {
411    let ApiRequest::Chat(chat_request) = request.api_request.as_ref()? else {
412        return None;
413    };
414    chat_api_response_from_generated_text(chat_request, text, finish_reason).map(ApiResponse::Chat)
415}
416
417pub fn chat_api_may_emit_tool_or_function_call(chat_request: &ApiChatRequest) -> bool {
418    (!chat_request.tools.is_empty() && !api_tool_choice_is_none(chat_request))
419        || (!chat_request.legacy_functions.is_empty()
420            && !api_function_call_choice_is_none(chat_request))
421}
422
423pub fn chat_api_response_from_generated_text(
424    chat_request: &ApiChatRequest,
425    text: &str,
426    finish_reason: FinishReason,
427) -> Option<ApiChatResponse> {
428    if !matches!(finish_reason, FinishReason::Stop | FinishReason::EOS) {
429        return None;
430    }
431
432    if !chat_request.tools.is_empty() && !api_tool_choice_is_none(chat_request) {
433        if let Some(tool_calls) = parse_tool_calls_from_generated_text(text, chat_request) {
434            return Some(ApiChatResponse {
435                message: ApiChatMessage {
436                    role: ApiMessageRole::Assistant,
437                    content: String::new(),
438                    name: None,
439                    tool_calls,
440                    tool_call_id: None,
441                    function_call: None,
442                },
443                finish_reason: Some("tool_calls".to_string()),
444            });
445        }
446    }
447
448    if !chat_request.legacy_functions.is_empty() && !api_function_call_choice_is_none(chat_request)
449    {
450        if let Some(function_call) =
451            parse_legacy_function_call_from_generated_text(text, chat_request)
452        {
453            return Some(ApiChatResponse {
454                message: ApiChatMessage {
455                    role: ApiMessageRole::Assistant,
456                    content: String::new(),
457                    name: None,
458                    tool_calls: Vec::new(),
459                    tool_call_id: None,
460                    function_call: Some(function_call),
461                },
462                finish_reason: Some("function_call".to_string()),
463            });
464        }
465    }
466
467    None
468}
469
470fn api_tool_choice_is_none(chat_request: &ApiChatRequest) -> bool {
471    matches!(
472        chat_request.tool_choice.as_ref(),
473        Some(ApiToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none")
474    )
475}
476
477fn api_function_call_choice_is_none(chat_request: &ApiChatRequest) -> bool {
478    matches!(
479        chat_request.legacy_function_call.as_ref(),
480        Some(ApiFunctionCallChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none")
481    )
482}
483
484fn parse_tool_calls_from_generated_text(
485    text: &str,
486    chat_request: &ApiChatRequest,
487) -> Option<Vec<ApiToolCall>> {
488    if chat_request.tool_call_protocol == ApiToolCallProtocol::FunctionParameterXml {
489        if let Some(calls) = parse_function_parameter_xml_tool_calls(text, chat_request) {
490            return validate_parsed_tool_calls(calls);
491        }
492    }
493
494    let value = parse_json_value_from_generated_text(text)?;
495    if let Some(calls) = value.get("tool_calls").and_then(|value| value.as_array()) {
496        if calls.len() > MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE {
497            return None;
498        }
499        let parsed = calls
500            .iter()
501            .enumerate()
502            .map(|(index, value)| parse_tool_call_value(value, index, chat_request))
503            .collect::<Option<Vec<_>>>()?;
504        return validate_parsed_tool_calls(parsed);
505    }
506    if let Some(tool_call) = value.get("tool_call") {
507        return parse_tool_call_value(tool_call, 0, chat_request)
508            .and_then(|call| validate_parsed_tool_calls(vec![call]));
509    }
510    if let Some(tool_call) = parse_wrapped_tool_call_value(&value, 0, chat_request) {
511        return validate_parsed_tool_calls(vec![tool_call]);
512    }
513    parse_tool_call_value(&value, 0, chat_request)
514        .or_else(|| parse_forced_tool_arguments_value(&value, 0, chat_request))
515        .and_then(|call| validate_parsed_tool_calls(vec![call]))
516}
517
518fn validate_parsed_tool_calls(calls: Vec<ApiToolCall>) -> Option<Vec<ApiToolCall>> {
519    if calls.is_empty() || calls.len() > MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE {
520        return None;
521    }
522    for (index, call) in calls.iter().enumerate() {
523        if calls[..index].iter().any(|previous| {
524            previous.function.name == call.function.name
525                && previous.function.arguments == call.function.arguments
526        }) {
527            return None;
528        }
529    }
530    Some(calls)
531}
532
533fn parse_function_parameter_xml_tool_calls(
534    text: &str,
535    chat_request: &ApiChatRequest,
536) -> Option<Vec<ApiToolCall>> {
537    const TOOL_START: &str = "<tool_call>";
538    const TOOL_END: &str = "</tool_call>";
539    const FUNCTION_START: &str = "<function=";
540    const FUNCTION_END: &str = "</function>";
541
542    let mut remaining = text;
543    let mut calls = Vec::new();
544    while let Some(tool_start) = remaining.find(TOOL_START) {
545        if calls.len() == MAX_PARALLEL_TOOL_CALLS_PER_RESPONSE {
546            return None;
547        }
548        remaining = &remaining[tool_start + TOOL_START.len()..];
549        let tool_end = remaining.find(TOOL_END)?;
550        let block = &remaining[..tool_end];
551        remaining = &remaining[tool_end + TOOL_END.len()..];
552
553        let Some(function_start) = block.find(FUNCTION_START) else {
554            return None;
555        };
556        if !block[..function_start].trim().is_empty() {
557            return None;
558        }
559        let function = &block[function_start + FUNCTION_START.len()..];
560        let Some(name_end) = function.find('>') else {
561            return None;
562        };
563        let name = function[..name_end].trim();
564        if !api_tool_name_allowed(chat_request, name) {
565            return None;
566        }
567        let arguments_end = function[name_end + 1..]
568            .find(FUNCTION_END)
569            .map(|offset| name_end + 1 + offset)?;
570        if !function[arguments_end + FUNCTION_END.len()..]
571            .trim()
572            .is_empty()
573        {
574            return None;
575        }
576        let parameter_schema = chat_request
577            .tools
578            .iter()
579            .find(|tool| tool.tool_type == "function" && tool.function.name == name)
580            .and_then(|tool| tool.function.parameters.as_ref());
581        let arguments = parse_function_parameter_xml_arguments(
582            &function[name_end + 1..arguments_end],
583            parameter_schema,
584        )?;
585        let arguments = serde_json::to_string(&arguments).ok()?;
586        calls.push(ApiToolCall {
587            id: format!("call_{}", calls.len()),
588            tool_type: "function".to_string(),
589            function: ApiFunctionCall {
590                name: name.to_string(),
591                arguments,
592            },
593        });
594    }
595
596    (!calls.is_empty()).then_some(calls)
597}
598
599fn parse_function_parameter_xml_arguments(
600    text: &str,
601    parameter_schema: Option<&serde_json::Value>,
602) -> Option<serde_json::Map<String, serde_json::Value>> {
603    const PARAMETER_START: &str = "<parameter=";
604    const PARAMETER_END: &str = "</parameter>";
605
606    let mut arguments = serde_json::Map::new();
607    let mut schema_probe = parameter_schema.map(XmlParameterSchemaProbe::new);
608    let mut remaining = text;
609    while let Some(parameter_start) = remaining.find(PARAMETER_START) {
610        remaining = &remaining[parameter_start + PARAMETER_START.len()..];
611        let Some(name_end) = remaining.find('>') else {
612            return None;
613        };
614        let name = remaining[..name_end].trim();
615        remaining = &remaining[name_end + 1..];
616        if name.is_empty() {
617            return None;
618        }
619        let value_end = remaining.find(PARAMETER_END)?;
620        if arguments.contains_key(name) {
621            return None;
622        }
623        let value = strip_xml_parameter_wrapper_newlines(&remaining[..value_end]);
624        let value = schema_probe.as_mut().map_or_else(
625            || serde_json::Value::String(value.to_string()),
626            |probe| probe.decode(name, value),
627        );
628        arguments.insert(name.to_string(), value);
629        remaining = &remaining[value_end + PARAMETER_END.len()..];
630    }
631    Some(arguments)
632}
633
634/// Qwen-style function XML renders one structural newline immediately inside
635/// each parameter tag. Remove only that framing while preserving whitespace
636/// that belongs to the argument itself, such as code indentation or a final
637/// newline used by exact-match edit tools.
638fn strip_xml_parameter_wrapper_newlines(value: &str) -> &str {
639    if let Some(value) = value.strip_prefix("\r\n") {
640        return value.strip_suffix("\r\n").unwrap_or(value);
641    }
642    if let Some(value) = value.strip_prefix('\n') {
643        if value.ends_with("\r\n") {
644            return value;
645        }
646        return value.strip_suffix('\n').unwrap_or(value);
647    }
648    value
649}
650
651fn parse_wrapped_tool_call_value(
652    value: &serde_json::Value,
653    index: usize,
654    chat_request: &ApiChatRequest,
655) -> Option<ApiToolCall> {
656    for key in ["auto", "tool", "tool_call", "auto_tool_response"] {
657        if let Some(wrapped) = value.get(key) {
658            if let Some(call) = parse_tool_call_value(wrapped, index, chat_request) {
659                return Some(call);
660            }
661        }
662    }
663    None
664}
665
666fn parse_tool_call_value(
667    value: &serde_json::Value,
668    index: usize,
669    chat_request: &ApiChatRequest,
670) -> Option<ApiToolCall> {
671    let tool_type = value
672        .get("type")
673        .and_then(|value| value.as_str())
674        .unwrap_or("function");
675    if tool_type != "function" {
676        return None;
677    }
678    let function = value.get("function").unwrap_or(value);
679    let name = function
680        .as_str()
681        .or_else(|| function.get("name").and_then(|value| value.as_str()))
682        .or_else(|| function.get("tool").and_then(|value| value.as_str()))
683        .or_else(|| value.get("name").and_then(|value| value.as_str()))?;
684    if !api_tool_name_allowed(chat_request, name) {
685        return None;
686    }
687    let arguments = api_arguments_to_string(
688        function
689            .get("arguments")
690            .or_else(|| function.get("parameters"))
691            .or_else(|| value.get("arguments"))
692            .or_else(|| value.get("parameters")),
693    );
694    let id = value
695        .get("id")
696        .and_then(|value| value.as_str())
697        .map(str::to_string)
698        .unwrap_or_else(|| format!("call_{index}"));
699
700    Some(ApiToolCall {
701        id,
702        tool_type: "function".to_string(),
703        function: ApiFunctionCall {
704            name: name.to_string(),
705            arguments,
706        },
707    })
708}
709
710fn parse_forced_tool_arguments_value(
711    value: &serde_json::Value,
712    index: usize,
713    chat_request: &ApiChatRequest,
714) -> Option<ApiToolCall> {
715    let tool = unwrapped_tool_arguments_target(chat_request, value)?;
716    if value.get("tool_calls").is_some()
717        || value.get("tool_call").is_some()
718        || value.get("function").is_some()
719        || value.get("name").is_some()
720    {
721        return None;
722    }
723
724    Some(ApiToolCall {
725        id: format!("call_{index}"),
726        tool_type: "function".to_string(),
727        function: ApiFunctionCall {
728            name: tool.function.name.clone(),
729            arguments: serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
730        },
731    })
732}
733
734fn unwrapped_tool_arguments_target<'a>(
735    chat_request: &'a ApiChatRequest,
736    value: &serde_json::Value,
737) -> Option<&'a ApiTool> {
738    if let Some(name) = forced_tool_choice_name(chat_request) {
739        return chat_request
740            .tools
741            .iter()
742            .find(|tool| tool.tool_type == "function" && tool.function.name == name);
743    }
744
745    if matches!(
746        chat_request.tool_choice.as_ref(),
747        Some(ApiToolChoice::Mode(mode)) if !mode.eq_ignore_ascii_case("auto")
748    ) {
749        return None;
750    }
751
752    let tool = single_function_tool(chat_request)?;
753    if !value_looks_like_tool_arguments(value, tool) {
754        return None;
755    }
756    Some(tool)
757}
758
759fn value_looks_like_tool_arguments(value: &serde_json::Value, tool: &ApiTool) -> bool {
760    let Some(arguments) = value.as_object() else {
761        return false;
762    };
763    if arguments.is_empty() {
764        return false;
765    }
766    let Some(properties) = tool
767        .function
768        .parameters
769        .as_ref()
770        .and_then(|parameters| parameters.get("properties"))
771        .and_then(|properties| properties.as_object())
772    else {
773        return false;
774    };
775    arguments.keys().all(|key| properties.contains_key(key))
776}
777
778fn forced_tool_choice_name(chat_request: &ApiChatRequest) -> Option<&str> {
779    match chat_request.tool_choice.as_ref() {
780        Some(ApiToolChoice::Function {
781            tool_type,
782            function,
783        }) if tool_type == "function" && api_tool_name_allowed(chat_request, &function.name) => {
784            Some(function.name.as_str())
785        }
786        Some(ApiToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("required") => {
787            single_function_tool(chat_request).map(|tool| tool.function.name.as_str())
788        }
789        _ => None,
790    }
791}
792
793fn single_function_tool(chat_request: &ApiChatRequest) -> Option<&ApiTool> {
794    let mut tools = chat_request
795        .tools
796        .iter()
797        .filter(|tool| tool.tool_type == "function");
798    let tool = tools.next()?;
799    tools.next().is_none().then_some(tool)
800}
801
802fn parse_legacy_function_call_from_generated_text(
803    text: &str,
804    chat_request: &ApiChatRequest,
805) -> Option<ApiFunctionCall> {
806    let value = parse_json_value_from_generated_text(text)?;
807    let function = value.get("function_call").unwrap_or(&value);
808    let name = function.get("name").and_then(|value| value.as_str())?;
809    if !api_function_name_allowed(chat_request, name) {
810        return None;
811    }
812    Some(ApiFunctionCall {
813        name: name.to_string(),
814        arguments: api_arguments_to_string(function.get("arguments")),
815    })
816}
817
818fn api_tool_name_allowed(chat_request: &ApiChatRequest, name: &str) -> bool {
819    match chat_request.tool_choice.as_ref() {
820        Some(ApiToolChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none") => false,
821        Some(ApiToolChoice::Function {
822            tool_type,
823            function,
824        }) => {
825            tool_type == "function"
826                && function.name == name
827                && chat_request
828                    .tools
829                    .iter()
830                    .any(|tool| tool.function.name == name)
831        }
832        _ => chat_request
833            .tools
834            .iter()
835            .any(|tool| tool.function.name == name),
836    }
837}
838
839fn api_function_name_allowed(chat_request: &ApiChatRequest, name: &str) -> bool {
840    match chat_request.legacy_function_call.as_ref() {
841        Some(ApiFunctionCallChoice::Mode(mode)) if mode.eq_ignore_ascii_case("none") => false,
842        Some(ApiFunctionCallChoice::Function { name: selected }) => {
843            selected == name
844                && chat_request
845                    .legacy_functions
846                    .iter()
847                    .any(|function| function.name == name)
848        }
849        _ => chat_request
850            .legacy_functions
851            .iter()
852            .any(|function| function.name == name),
853    }
854}
855
856fn parse_json_value_from_generated_text(text: &str) -> Option<serde_json::Value> {
857    let trimmed = strip_single_json_fence(text.trim());
858    serde_json::from_str(trimmed).ok().or_else(|| {
859        let start = trimmed.find('{')?;
860        let end = trimmed.rfind('}')?;
861        (start <= end)
862            .then(|| serde_json::from_str(&trimmed[start..=end]).ok())
863            .flatten()
864    })
865}
866
867fn strip_single_json_fence(text: &str) -> &str {
868    let Some(rest) = text.strip_prefix("```") else {
869        return text;
870    };
871    let rest = rest.strip_prefix("json").unwrap_or(rest).trim_start();
872    rest.strip_suffix("```").map(str::trim).unwrap_or(text)
873}
874
875fn api_arguments_to_string(arguments: Option<&serde_json::Value>) -> String {
876    match arguments {
877        Some(serde_json::Value::String(raw)) => raw.clone(),
878        Some(value) => serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
879        None => "{}".to_string(),
880    }
881}
882
883impl InferenceRequest {
884    /// Create a new inference request
885    pub fn new(prompt: impl Into<String>, model_id: impl Into<ModelId>) -> Self {
886        Self {
887            id: RequestId::new(),
888            prompt: prompt.into(),
889            model_id: model_id.into(),
890            sampling_params: SamplingParams::default(),
891            stream: false,
892            priority: Priority::default(),
893            client_id: None,
894            session_id: None,
895            created_at: Utc::now(),
896            api_request: None,
897            evidence_request: InferenceEvidenceRequest::default(),
898            metadata: HashMap::new(),
899        }
900    }
901
902    /// Set sampling parameters
903    pub fn with_sampling_params(mut self, params: SamplingParams) -> Self {
904        self.sampling_params = params;
905        self
906    }
907
908    /// Enable streaming
909    pub fn with_stream(mut self, stream: bool) -> Self {
910        self.stream = stream;
911        self
912    }
913
914    /// Set priority
915    pub fn with_priority(mut self, priority: Priority) -> Self {
916        self.priority = priority;
917        self
918    }
919
920    /// Set client ID
921    pub fn with_client_id(mut self, client_id: impl Into<ClientId>) -> Self {
922        self.client_id = Some(client_id.into());
923        self
924    }
925
926    /// Set session ID
927    pub fn with_session_id(mut self, session_id: SessionId) -> Self {
928        self.session_id = Some(session_id);
929        self
930    }
931
932    /// Set structured product/API request context.
933    pub fn with_api_request(mut self, api_request: ApiRequest) -> Self {
934        self.api_request = Some(api_request);
935        self
936    }
937
938    /// Request prompt-token evidence from the execution boundary.
939    pub fn with_prompt_token_evidence(mut self) -> Self {
940        self.evidence_request.capture_prompt_token_ids = true;
941        self
942    }
943
944    /// Request engine token-commit timing evidence.
945    pub fn with_engine_token_timing_evidence(mut self) -> Self {
946        self.evidence_request.capture_engine_token_timing = true;
947        self
948    }
949
950    /// Add metadata
951    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
952        self.metadata.insert(key.into(), value);
953        self
954    }
955}
956
957/// Inference response
958#[derive(Debug, Clone, Serialize, Deserialize)]
959pub struct InferenceResponse {
960    /// Request ID this response corresponds to
961    pub request_id: RequestId,
962    /// Generated text
963    pub text: String,
964    /// Generated token IDs
965    pub tokens: Vec<TokenId>,
966    /// Reason for completion
967    pub finish_reason: FinishReason,
968    /// Token usage statistics
969    pub usage: TokenUsage,
970    /// Total latency in milliseconds
971    pub latency_ms: u64,
972    /// Response creation timestamp
973    pub created_at: DateTime<Utc>,
974    /// Additional response metadata
975    pub metadata: HashMap<String, serde_json::Value>,
976    /// Structured product/API response context. Engines that can produce
977    /// product-native outputs, such as assistant tool calls, can populate
978    /// this without overloading plain text or ad hoc metadata.
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub api_response: Option<ApiResponse>,
981    /// Optional engine-boundary evidence requested by the caller.
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub execution_evidence: Option<InferenceExecutionEvidence>,
984}
985
986/// Streaming response chunk
987#[derive(Debug, Clone, Serialize, Deserialize)]
988pub struct StreamChunk {
989    /// Request ID this chunk corresponds to
990    pub request_id: RequestId,
991    /// Text delta for this chunk
992    pub text: String,
993    /// Token ID for this chunk (if available)
994    pub token: Option<TokenId>,
995    /// Finish reason if this is the final chunk
996    pub finish_reason: Option<FinishReason>,
997    /// Token usage (typically only in final chunk)
998    pub usage: Option<TokenUsage>,
999    /// Chunk creation timestamp
1000    pub created_at: DateTime<Utc>,
1001    /// Chunk metadata
1002    pub metadata: HashMap<String, serde_json::Value>,
1003    /// Structured product/API response context for final streaming chunks.
1004    /// This mirrors `InferenceResponse::api_response` so streaming endpoints
1005    /// can return native tool/function-call payloads without reparsing text.
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub api_response: Option<ApiResponse>,
1008    /// Optional engine-boundary evidence, emitted on the final chunk.
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub execution_evidence: Option<InferenceExecutionEvidence>,
1011}
1012
1013/// Batch request for processing multiple requests together
1014#[derive(Debug, Clone, Serialize, Deserialize)]
1015pub struct BatchRequest {
1016    /// Batch identifier
1017    pub batch_id: BatchId,
1018    /// Requests in this batch
1019    pub requests: Vec<InferenceRequest>,
1020    /// Maximum sequence length for this batch
1021    pub max_sequence_length: usize,
1022    /// Batch creation timestamp
1023    pub created_at: DateTime<Utc>,
1024}
1025
1026impl BatchRequest {
1027    /// Create a new batch request
1028    pub fn new(requests: Vec<InferenceRequest>) -> Self {
1029        let max_sequence_length = requests
1030            .iter()
1031            .map(|r| r.sampling_params.max_tokens)
1032            .max()
1033            .unwrap_or(512);
1034
1035        Self {
1036            batch_id: BatchId::new(),
1037            requests,
1038            max_sequence_length,
1039            created_at: Utc::now(),
1040        }
1041    }
1042
1043    /// Get the number of requests in this batch
1044    pub fn size(&self) -> usize {
1045        self.requests.len()
1046    }
1047
1048    /// Check if batch is empty
1049    pub fn is_empty(&self) -> bool {
1050        self.requests.is_empty()
1051    }
1052}
1053
1054/// Request state in the scheduler
1055#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1056pub enum RequestState {
1057    /// Request is waiting in queue
1058    Waiting,
1059    /// Request is being processed
1060    Running,
1061    /// Request was preempted and is waiting to resume
1062    Preempted,
1063    /// Request completed successfully
1064    Completed,
1065    /// Request failed with error
1066    Failed,
1067    /// Request was cancelled
1068    Cancelled,
1069}
1070
1071/// Scheduled request with additional state information
1072#[derive(Debug, Clone)]
1073pub struct ScheduledRequest {
1074    /// The original request
1075    pub request: InferenceRequest,
1076    /// Current state in scheduler
1077    pub state: RequestState,
1078    /// Allocated cache blocks
1079    pub allocated_blocks: Vec<crate::BlockId>,
1080    /// Number of tokens processed so far
1081    pub tokens_processed: usize,
1082    /// Estimated completion time
1083    pub estimated_completion: Option<DateTime<Utc>>,
1084}
1085
1086impl ScheduledRequest {
1087    /// Create a new scheduled request
1088    pub fn new(request: InferenceRequest) -> Self {
1089        Self {
1090            request,
1091            state: RequestState::Waiting,
1092            allocated_blocks: Vec::new(),
1093            tokens_processed: 0,
1094            estimated_completion: None,
1095        }
1096    }
1097
1098    /// Update request state
1099    pub fn set_state(&mut self, state: RequestState) {
1100        self.state = state;
1101    }
1102
1103    /// Add allocated cache blocks
1104    pub fn add_blocks(&mut self, blocks: Vec<crate::BlockId>) {
1105        self.allocated_blocks.extend(blocks);
1106    }
1107
1108    /// Update tokens processed
1109    pub fn update_progress(&mut self, tokens_processed: usize) {
1110        self.tokens_processed = tokens_processed;
1111    }
1112}