Skip to main content

ferrum_types/requests/
native_projection.rs

1//! Monotonic output projection for declared automatic native XML tools.
2//!
3//! Only the initial reasoning envelope owns a reasoning channel. Once the
4//! response enters its body, later reasoning tags are literal text. Tool
5//! parameters are parsed by the native framing owner, never by a tag regex.
6
7use super::{
8    xml_tool_calls, ApiChatMessage, ApiChatRequest, ApiChatResponse, ApiMessageRole, ApiRequest,
9    ApiToolCallProtocol, ApiToolChoice, InferenceRequest, PROMPT_OPENED_REASONING_METADATA_KEY,
10};
11use crate::{
12    FinishReason, ModelOutputProtocol, ParsedReasoningResponse, THINK_END_TAG, THINK_START_TAG,
13};
14
15const TOOL_START: &str = "<tool_call>";
16
17/// Final projection shared by engine completion and both HTTP response modes.
18#[derive(Debug, Clone)]
19pub struct NativeChatOutputProjection {
20    pub visible: ParsedReasoningResponse,
21    pub api_response: Option<ApiChatResponse>,
22}
23
24#[derive(Debug, Clone, Copy)]
25enum InitialBoundary {
26    Pending,
27    Body,
28    Reasoning { start: usize, prefix_end: usize },
29}
30
31#[derive(Debug, Clone, Copy)]
32enum PrefixPhase {
33    Initial,
34    Reasoning {
35        start: usize,
36        scan: usize,
37        prefix_end: usize,
38    },
39    Body {
40        scan: usize,
41        skip_newlines: bool,
42    },
43    Held,
44}
45
46/// Holds uncertain framing and exposes only an irrevocable ordinary-text prefix.
47/// Calls and their arguments are withheld until the original terminal contract
48/// permits them. Unknown generation state and other output protocols opt out.
49pub struct NativeChatOutputProjector {
50    request: ApiChatRequest,
51    started_in_think: bool,
52    raw: String,
53    phase: PrefixPhase,
54    visible_prefix: String,
55    closed_reasoning: Option<(usize, usize)>,
56}
57
58impl NativeChatOutputProjector {
59    pub fn for_request(request: &InferenceRequest) -> Option<Self> {
60        let ApiRequest::Chat(chat) = request.api_request.as_ref()? else {
61            return None;
62        };
63        let started = request
64            .metadata
65            .get(PROMPT_OPENED_REASONING_METADATA_KEY)?
66            .as_bool()?;
67        Self::new(chat, request.sampling_params.model_output_protocol, started)
68    }
69
70    pub fn new(
71        request: &ApiChatRequest,
72        protocol: ModelOutputProtocol,
73        started_in_think: bool,
74    ) -> Option<Self> {
75        let automatic = request.tool_choice.as_ref().is_none_or(|choice| {
76            matches!(choice, ApiToolChoice::Mode(mode) if mode.eq_ignore_ascii_case("auto"))
77        });
78        let plain = request
79            .response_format
80            .as_ref()
81            .is_none_or(|format| format.format_type == "text");
82        if request.tool_call_protocol != ApiToolCallProtocol::FunctionParameterXml
83            || protocol != ModelOutputProtocol::Text
84            || request.tools.is_empty()
85            || !request.legacy_functions.is_empty()
86            || request.legacy_function_call.is_some()
87            || !automatic
88            || !plain
89        {
90            return None;
91        }
92        Some(Self {
93            request: request.clone(),
94            started_in_think,
95            raw: String::new(),
96            phase: PrefixPhase::Initial,
97            visible_prefix: String::new(),
98            closed_reasoning: None,
99        })
100    }
101
102    pub fn push(&mut self, text: &str) {
103        self.raw.push_str(text);
104        loop {
105            match self.phase {
106                PrefixPhase::Initial => match initial_boundary(&self.raw, self.started_in_think) {
107                    InitialBoundary::Pending => return,
108                    InitialBoundary::Body => {
109                        self.phase = PrefixPhase::Body {
110                            scan: 0,
111                            skip_newlines: false,
112                        }
113                    }
114                    InitialBoundary::Reasoning { start, prefix_end } => {
115                        self.phase = PrefixPhase::Reasoning {
116                            start,
117                            scan: start,
118                            prefix_end,
119                        };
120                    }
121                },
122                PrefixPhase::Reasoning {
123                    start,
124                    mut scan,
125                    prefix_end,
126                } => {
127                    while scan < self.raw.len() {
128                        let tail = &self.raw[scan..];
129                        if tail.starts_with(TOOL_START) {
130                            // A reasoning-owned call may take precedence over later
131                            // body text. Its payload can contain a literal closer.
132                            self.phase = PrefixPhase::Held;
133                            return;
134                        }
135                        if tail.starts_with(THINK_END_TAG) {
136                            self.closed_reasoning = (scan > start).then_some((start, scan));
137                            self.visible_prefix.push_str(&self.raw[..prefix_end]);
138                            self.phase = PrefixPhase::Body {
139                                scan: scan + THINK_END_TAG.len(),
140                                skip_newlines: true,
141                            };
142                            break;
143                        }
144                        if TOOL_START.starts_with(tail) || THINK_END_TAG.starts_with(tail) {
145                            self.phase = PrefixPhase::Reasoning {
146                                start,
147                                scan,
148                                prefix_end,
149                            };
150                            return;
151                        }
152                        scan += tail.chars().next().expect("nonempty suffix").len_utf8();
153                    }
154                    if matches!(self.phase, PrefixPhase::Reasoning { .. }) {
155                        self.phase = PrefixPhase::Reasoning {
156                            start,
157                            scan,
158                            prefix_end,
159                        };
160                        return;
161                    }
162                }
163                PrefixPhase::Body {
164                    mut scan,
165                    mut skip_newlines,
166                } => {
167                    // Match terminal projection's removal of framing newlines
168                    // directly following the first reasoning closer.
169                    if skip_newlines {
170                        while self
171                            .raw
172                            .as_bytes()
173                            .get(scan)
174                            .is_some_and(|byte| matches!(byte, b'\r' | b'\n'))
175                        {
176                            scan += 1;
177                        }
178                        skip_newlines = scan == self.raw.len();
179                    }
180                    let tail = &self.raw[scan..];
181                    if let Some(marker) = tail.find(TOOL_START) {
182                        self.visible_prefix.push_str(&tail[..marker]);
183                        self.phase = PrefixPhase::Held;
184                        return;
185                    }
186                    let held = partial_suffix_len(tail, TOOL_START);
187                    let end = self.raw.len() - held;
188                    self.visible_prefix.push_str(&self.raw[scan..end]);
189                    self.phase = PrefixPhase::Body {
190                        scan: end,
191                        skip_newlines,
192                    };
193                    return;
194                }
195                PrefixPhase::Held => return,
196            }
197        }
198    }
199
200    /// A cumulative prefix, suitable for the existing sent-length bookkeeping.
201    /// Whitespace alone remains pending because a tools-only result drops it.
202    pub fn visible_prefix(&self) -> &str {
203        if self.visible_prefix.trim().is_empty() {
204            ""
205        } else {
206            &self.visible_prefix
207        }
208    }
209
210    /// The completed initial reasoning channel can precede an irrevocable body.
211    /// A block with unresolved tool intent remains held until completion.
212    pub fn reasoning_prefix(&self) -> Option<&str> {
213        self.closed_reasoning
214            .map(|(start, end)| &self.raw[start..end])
215    }
216
217    pub fn finish(self, finish_reason: FinishReason) -> NativeChatOutputProjection {
218        let (mut content, reasoning) =
219            split_initial_reasoning(&self.raw, self.started_in_think, &self.request);
220        let api_response = if matches!(finish_reason, FinishReason::Stop | FinishReason::EOS) {
221            let reasoning_calls = reasoning
222                .as_deref()
223                .and_then(|text| xml_tool_calls::parse_with_content(text, &self.request, false));
224            let parsed = if let Some(parsed) = reasoning_calls {
225                content.clear();
226                Some((String::new(), parsed.calls))
227            } else {
228                xml_tool_calls::parse_with_content(&content, &self.request, false)
229                    .map(|parsed| (parsed.content, parsed.calls))
230            };
231            parsed.map(|(outside, calls)| {
232                content = outside.clone();
233                ApiChatResponse {
234                    message: ApiChatMessage {
235                        role: ApiMessageRole::Assistant,
236                        content: outside,
237                        name: None,
238                        tool_calls: calls,
239                        tool_call_id: None,
240                        function_call: None,
241                    },
242                    finish_reason: Some("tool_calls".to_owned()),
243                }
244            })
245        } else {
246            // In particular, Length never turns a partial envelope into an
247            // executable call. Its text/usage retain the terminal fallback.
248            None
249        };
250        NativeChatOutputProjection {
251            visible: ParsedReasoningResponse { content, reasoning },
252            api_response,
253        }
254    }
255}
256
257fn initial_boundary(raw: &str, started: bool) -> InitialBoundary {
258    if started {
259        if raw.len() < THINK_START_TAG.len() && THINK_START_TAG.starts_with(raw) {
260            return InitialBoundary::Pending;
261        }
262        let start = if raw.starts_with(THINK_START_TAG) {
263            THINK_START_TAG.len()
264        } else {
265            0
266        };
267        return InitialBoundary::Reasoning {
268            start,
269            prefix_end: 0,
270        };
271    }
272    let trimmed = raw.trim_start();
273    if trimmed.len() < THINK_START_TAG.len() && THINK_START_TAG.starts_with(trimmed) {
274        return InitialBoundary::Pending;
275    }
276    if trimmed.starts_with(THINK_START_TAG) {
277        let prefix_end = raw.len() - trimmed.len();
278        InitialBoundary::Reasoning {
279            start: prefix_end + THINK_START_TAG.len(),
280            prefix_end,
281        }
282    } else {
283        InitialBoundary::Body
284    }
285}
286
287fn partial_suffix_len(text: &str, marker: &str) -> usize {
288    (1..marker.len())
289        .rev()
290        .find(|length| text.ends_with(&marker[..*length]))
291        .unwrap_or(0)
292}
293
294fn split_initial_reasoning(
295    raw: &str,
296    started: bool,
297    request: &ApiChatRequest,
298) -> (String, Option<String>) {
299    let (start, prefix_end) = match initial_boundary(raw, started) {
300        InitialBoundary::Body => return (raw.to_owned(), None),
301        InitialBoundary::Pending if !started => return (raw.to_owned(), None),
302        InitialBoundary::Pending => {
303            return (String::new(), (!raw.is_empty()).then(|| raw.to_owned()))
304        }
305        InitialBoundary::Reasoning { start, prefix_end } => (start, prefix_end),
306    };
307    let mut scan = start;
308    while scan < raw.len() {
309        let tail = &raw[scan..];
310        if tail.starts_with(THINK_END_TAG) {
311            let reasoning = &raw[start..scan];
312            let body = tail[THINK_END_TAG.len()..].trim_start_matches(['\r', '\n']);
313            return (
314                format!("{}{body}", &raw[..prefix_end]),
315                (!reasoning.is_empty()).then(|| reasoning.to_owned()),
316            );
317        }
318        if tail.starts_with(TOOL_START) && xml_tool_calls::has_native_envelope(tail) {
319            // The structural parser owns parameter boundaries, including tag
320            // literals. An incomplete native call cannot expose its tail as body.
321            let Some((_, rest)) = xml_tool_calls::parse_one(tail, request, 0) else {
322                break;
323            };
324            scan = raw.len() - rest.len();
325        } else {
326            scan += tail.chars().next().expect("nonempty suffix").len_utf8();
327        }
328    }
329    let reasoning = &raw[start..];
330    (
331        raw[..prefix_end].to_owned(),
332        (!reasoning.is_empty()).then(|| reasoning.to_owned()),
333    )
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use serde_json::{json, Value};
340
341    fn request() -> ApiChatRequest {
342        serde_json::from_value(json!({
343            "messages": [], "tool_call_protocol": "function_parameter_xml",
344            "tools": [{"type": "function", "function": {
345                "name": "write", "parameters": {
346                    "type": "object", "properties": {"content": {"type": "string"}}
347                }
348            }}]
349        }))
350        .unwrap()
351    }
352
353    fn call(content: &str) -> String {
354        format!("<tool_call><function=write><parameter=content>{content}</parameter></function></tool_call>")
355    }
356
357    fn project(raw: &str, started: bool, finish: FinishReason) -> NativeChatOutputProjection {
358        let mut projector =
359            NativeChatOutputProjector::new(&request(), ModelOutputProtocol::Text, started).unwrap();
360        projector.push(raw);
361        projector.finish(finish)
362    }
363
364    #[test]
365    fn native_projection_keeps_every_published_prefix_at_every_chunk_boundary() {
366        let cases = [
367            (
368                false,
369                "A Unicode body: 字节. Later </think> and <think> are literal.".into(),
370            ),
371            (false, " \n<think>Private.</think>\r\nPublic body.".into()),
372            (true, "Private.</think>\r\nPublic body.".into()),
373            (true, "<think>Private.</think>\r\nPublic body.".into()),
374            (
375                false,
376                format!(
377                    "Before.\n{}\nAfter.",
378                    call("literal </think> and </tool_call>")
379                ),
380            ),
381            (false, format!(" \n{}\n ", call("only payload"))),
382            (
383                false,
384                format!(
385                    "<think>Private. {}</think>Discarded body. {}",
386                    call("reasoning choice"),
387                    call("body choice")
388                ),
389            ),
390            (
391                true,
392                format!(
393                    "Private. {}</think>Discarded body.",
394                    call("<think>literal</think> </tool_call>")
395                ),
396            ),
397            (
398                false,
399                format!(
400                    "Before. {}<tool_call><function=write><parameter=content>unfinished",
401                    call("first")
402                ),
403            ),
404            (false, "<thi".into()),
405            (true, "Unfinished private reasoning <tool_".into()),
406        ];
407        for (started, raw) in cases {
408            for finish in [FinishReason::Stop, FinishReason::EOS, FinishReason::Length] {
409                let final_projection = project(&raw, started, finish);
410                // Exercise both arbitrary two-chunk splits and one character
411                // per chunk, including every framing and UTF-8 boundary.
412                for split in raw
413                    .char_indices()
414                    .map(|(index, _)| index)
415                    .chain([raw.len()])
416                {
417                    let mut projector = NativeChatOutputProjector::new(
418                        &request(),
419                        ModelOutputProtocol::Text,
420                        started,
421                    )
422                    .unwrap();
423                    projector.push(&raw[..split]);
424                    assert!(
425                        final_projection
426                            .visible
427                            .content
428                            .starts_with(projector.visible_prefix()),
429                        "split {split}: {raw}"
430                    );
431                    projector.push(&raw[split..]);
432                    assert!(
433                        final_projection
434                            .visible
435                            .content
436                            .starts_with(projector.visible_prefix()),
437                        "final prefix: {raw}"
438                    );
439                    let chunked = projector.finish(finish);
440                    assert_eq!(chunked.visible, final_projection.visible);
441                    assert_eq!(chunked.api_response, final_projection.api_response);
442                }
443                let mut projector =
444                    NativeChatOutputProjector::new(&request(), ModelOutputProtocol::Text, started)
445                        .unwrap();
446                for character in raw.chars() {
447                    projector.push(character.encode_utf8(&mut [0; 4]));
448                    assert!(
449                        final_projection
450                            .visible
451                            .content
452                            .starts_with(projector.visible_prefix()),
453                        "character prefix: {raw}"
454                    );
455                }
456                assert_eq!(projector.finish(finish).visible, final_projection.visible);
457            }
458        }
459    }
460
461    #[test]
462    fn native_projection_never_reopens_body_or_guesses_json_calls() {
463        let text = "Explanation </think> <think>literal</think> {\"name\":\"write\",\"arguments\":{\"content\":\"x\"}}";
464        let projected = project(text, false, FinishReason::EOS);
465        assert_eq!(projected.visible.content, text);
466        assert_eq!(projected.visible.reasoning, None);
467        assert_eq!(projected.api_response, None);
468        let projected = project(
469            "<think>Private.</think>\r\nVisible <think>literal</think>.",
470            false,
471            FinishReason::Stop,
472        );
473        assert_eq!(projected.visible.content, "Visible <think>literal</think>.");
474        assert_eq!(projected.visible.reasoning.as_deref(), Some("Private."));
475    }
476
477    #[test]
478    fn native_projection_preserves_reasoning_owned_call_and_literal_parameters() {
479        let payload = "const MARK: &str = \"<think>literal</think> </tool_call>\";\r\n";
480        let reasoning = format!("Private. {}", call(payload));
481        let raw = format!("{reasoning}</think>Body call {}", call("must not win"));
482        let projected = project(&raw, true, FinishReason::Stop);
483        assert!(projected.visible.content.is_empty());
484        assert_eq!(
485            projected.visible.reasoning.as_deref(),
486            Some(reasoning.as_str())
487        );
488        let response = projected.api_response.unwrap();
489        assert_eq!(response.message.tool_calls.len(), 1);
490        let arguments: Value =
491            serde_json::from_str(&response.message.tool_calls[0].function.arguments).unwrap();
492        assert_eq!(arguments["content"], payload);
493        let limited = project(&raw, true, FinishReason::Length);
494        assert!(limited.api_response.is_none());
495        assert_eq!(
496            limited.visible.content,
497            format!("Body call {}", call("must not win"))
498        );
499        let unfinished = project(
500            "Private. <tool_call><function=write><parameter=content>literal </think> still payload",
501            true,
502            FinishReason::Length,
503        );
504        assert!(unfinished.visible.content.is_empty());
505        assert!(unfinished.api_response.is_none());
506    }
507
508    #[test]
509    fn native_projection_requires_known_render_state_and_keeps_other_contracts_buffered() {
510        let mut inference = InferenceRequest::new("prompt", "fixture");
511        inference.api_request = Some(ApiRequest::Chat(request()));
512        assert!(NativeChatOutputProjector::for_request(&inference).is_none());
513        inference
514            .metadata
515            .insert(PROMPT_OPENED_REASONING_METADATA_KEY.into(), json!(false));
516        assert!(NativeChatOutputProjector::for_request(&inference).is_some());
517        let mut required = request();
518        required.tool_choice = Some(ApiToolChoice::Mode("required".into()));
519        let mut named = request();
520        named.tool_choice = Some(
521            serde_json::from_value(json!({"type": "function", "function": {"name": "write"}}))
522                .unwrap(),
523        );
524        let mut hard = request();
525        hard.response_format =
526            Some(serde_json::from_value(json!({"type": "json_object"})).unwrap());
527        let mut legacy = request();
528        legacy.legacy_functions = vec![legacy.tools[0].function.clone()];
529        let mut json_request = request();
530        json_request.tool_call_protocol = ApiToolCallProtocol::Json;
531        for other in [required, named, hard, legacy, json_request] {
532            assert!(
533                NativeChatOutputProjector::new(&other, ModelOutputProtocol::Text, false).is_none()
534            );
535        }
536        for protocol in [
537            ModelOutputProtocol::HarmonyGptOss,
538            ModelOutputProtocol::GemmaThought,
539        ] {
540            assert!(NativeChatOutputProjector::new(&request(), protocol, false).is_none());
541        }
542    }
543}