1use 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#[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
46pub 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 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 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 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 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 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 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 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}