Skip to content
20 changes: 18 additions & 2 deletions lib/llm/src/preprocessor/prompt/template/formatters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ fn remove_known_non_jinja2_tags(template: &str) -> String {
.replace("{% endgeneration %}", "")
}

fn rewrite_python_dict_items_calls(template: &str) -> String {
template.replace(".items()", "|items")
}

impl JinjaEnvironment {
fn env(self) -> Environment<'static> {
self.env
Expand Down Expand Up @@ -112,7 +116,8 @@ impl HfTokenizerConfigJsonFormatter {
supports_add_generation_prompt = Some(true);
}
// Remove known non-standard tags before validation (they don't affect output)
let template_cleaned = remove_known_non_jinja2_tags(x);
let template_cleaned =
rewrite_python_dict_items_calls(&remove_known_non_jinja2_tags(x));
env.add_template_owned("default", template_cleaned.clone())?;
env.add_template_owned("tool_use", template_cleaned)?;
}
Expand All @@ -137,7 +142,8 @@ impl HfTokenizerConfigJsonFormatter {
supports_add_generation_prompt = Some(false);
}
// Remove known non-standard tags before validation (they don't affect output)
let template_cleaned = remove_known_non_jinja2_tags(v);
let template_cleaned =
rewrite_python_dict_items_calls(&remove_known_non_jinja2_tags(v));
env.add_template_owned(k.to_string(), template_cleaned)?;
}
}
Expand Down Expand Up @@ -198,4 +204,14 @@ mod tests {
let result = remove_known_non_jinja2_tags(template);
assert_eq!(result, "Start Part 1 middle Part 2");
}

#[test]
fn test_rewrite_python_dict_items_calls() {
let template = "{{ args.items() }} {% for k, v in data.items() %}{{ k }}{% endfor %}";
let result = rewrite_python_dict_items_calls(template);
assert_eq!(
result,
"{{ args|items }} {% for k, v in data|items %}{{ k }}{% endfor %}"
);
}
}
204 changes: 180 additions & 24 deletions lib/llm/src/protocols/openai/responses/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,32 +591,81 @@ fn parse_tool_call_text(text: &str) -> Vec<(String, String)> {
results
}

fn extract_think_blocks(text: &str) -> Vec<String> {
if !text.contains("<think>") && !text.contains("</think>") {
return Vec::new();
}

let mut extracted = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
let next_open = remaining.find("<think>");
let next_close = remaining.find("</think>");

match (next_open, next_close) {
(Some(open), Some(close)) if open < close => {
let content_start = open + "<think>".len();
let think = remaining[content_start..close].trim();
if !think.is_empty() {
extracted.push(think.to_string());
}
remaining = &remaining[close + "</think>".len()..];
}
(_, Some(close)) => {
let think = remaining[..close].trim();
if !think.is_empty() {
extracted.push(think.to_string());
}
remaining = &remaining[close + "</think>".len()..];
}
(Some(_), None) | (None, None) => break,
}
}

extracted
}

/// Strip `<tool_call>...</tool_call>` blocks and any `<think>...</think>` blocks from text.
/// Returns the original string (no allocation) if no tags are present.
fn strip_tool_call_text(text: &str) -> std::borrow::Cow<'_, str> {
let has_tool = text.contains("<tool_call>");
let has_think = text.contains("<think>");
let has_think = text.contains("<think>") || text.contains("</think>");
if !has_tool && !has_think {
return std::borrow::Cow::Borrowed(text);
}

fn strip_tag(input: &mut String, open: &str, close: &str) {
while let Some(start) = input.find(open) {
if let Some(end_offset) = input[start..].find(close) {
input.replace_range(start..start + end_offset + close.len(), "");
} else {
input.truncate(start);
break;
fn strip_tag(
input: &mut String,
open: &str,
close: &str,
strip_prefix_for_dangling_close: bool,
) {
loop {
let next_open = input.find(open);
let next_close = input.find(close);

match (next_open, next_close) {
(Some(start), Some(end)) if start < end => {
input.replace_range(start..end + close.len(), "");
}
(Some(start), _) => {
input.truncate(start);
break;
}
(_, Some(end)) if strip_prefix_for_dangling_close => {
input.replace_range(0..end + close.len(), "");
}
_ => break,
}
}
}

let mut result = text.to_string();
if has_tool {
strip_tag(&mut result, "<tool_call>", "</tool_call>");
strip_tag(&mut result, "<tool_call>", "</tool_call>", false);
}
if has_think {
strip_tag(&mut result, "<think>", "</think>");
strip_tag(&mut result, "<think>", "</think>", true);
}
std::borrow::Cow::Owned(result)
}
Expand Down Expand Up @@ -719,6 +768,19 @@ pub fn chat_completion_to_response(
}

// Map reasoning_content to a Reasoning output item
let content_text = match choice.message.content {
Some(dynamo_async_openai::types::ChatCompletionMessageContent::Text(text)) => {
Some(text)
}
Some(dynamo_async_openai::types::ChatCompletionMessageContent::Parts(_)) => {
tracing::warn!(
"Multimodal content in responses API not yet supported, using placeholder"
);
Some("[multimodal content]".to_string())
}
None => None,
};

if let Some(reasoning_text) = choice.message.reasoning_content
&& !reasoning_text.is_empty()
{
Expand All @@ -731,39 +793,44 @@ pub fn chat_completion_to_response(
encrypted_content: None,
status: Some(OutputStatus::Completed),
}));
} else if let Some(content_text) = content_text.as_ref() {
let think_blocks = extract_think_blocks(content_text);
if !think_blocks.is_empty() {
output.push(OutputItem::Reasoning(ReasoningItem {
id: format!("rs_{}", Uuid::new_v4().simple()),
summary: think_blocks
.into_iter()
.map(|text| SummaryPart::SummaryText(Summary { text }))
.collect(),
content: None,
encrypted_content: None,
status: Some(OutputStatus::Completed),
}));
}
}

// Handle text content -- also parse <tool_call> blocks from models
// that emit tool calls as text (e.g. Qwen3)
let content_text = match choice.message.content {
Some(dynamo_async_openai::types::ChatCompletionMessageContent::Text(text)) => {
Some(text)
}
Some(dynamo_async_openai::types::ChatCompletionMessageContent::Parts(_)) => {
tracing::warn!(
"Multimodal content in responses API not yet supported, using placeholder"
);
Some("[multimodal content]".to_string())
}
None => None,
};
if let Some(content_text) = content_text
&& !content_text.is_empty()
{
let parsed_calls = parse_tool_call_text(&content_text);
let remaining = strip_tool_call_text(&content_text);
if !parsed_calls.is_empty() {
for (name, arguments) in parsed_calls {
output.push(make_function_call(name, arguments));
}
let remaining = strip_tool_call_text(&content_text);
if !remaining.trim().is_empty() {
output.push(make_text_message(
message_id.clone(),
remaining.into_owned(),
));
}
} else {
output.push(make_text_message(message_id.clone(), content_text));
let visible_text = remaining.into_owned();
if !visible_text.trim().is_empty() {
output.push(make_text_message(message_id.clone(), visible_text));
}
}
}

Expand Down Expand Up @@ -1321,6 +1388,35 @@ thinking
assert!(!stripped.contains("<think>"));
}

#[test]
fn test_strip_tool_call_text_with_dangling_think_close_hides_prefix() {
let text = "private reasoning</think>Visible answer.";
let stripped = strip_tool_call_text(text);
assert_eq!(stripped, "Visible answer.");
}

#[test]
fn test_extract_think_blocks() {
let text = r#"<think>
first
</think>
visible
<think>second</think>"#;
assert_eq!(
extract_think_blocks(text),
vec!["first".to_string(), "second".to_string()]
);
}

#[test]
fn test_extract_think_blocks_with_dangling_close() {
let text = "private reasoning</think>Visible answer.";
assert_eq!(
extract_think_blocks(text),
vec!["private reasoning".to_string()]
);
}

// ── PR1: reasoning / text.format / service_tier pass-through tests ──

#[test]
Expand Down Expand Up @@ -1448,6 +1544,66 @@ thinking
assert_eq!(reasoning.effort, Some(ReasoningEffort::High));
}

#[test]
fn test_response_salvages_raw_think_blocks_and_hides_them_from_visible_text() {
#[allow(deprecated)]
let chat_resp = NvCreateChatCompletionResponse {
id: "chatcmpl-think".into(),
choices: vec![dynamo_async_openai::types::ChatChoice {
index: 0,
message: dynamo_async_openai::types::ChatCompletionResponseMessage {
content: Some(
dynamo_async_openai::types::ChatCompletionMessageContent::Text(
"<think>private chain of thought</think>Public answer.".to_string(),
),
),
refusal: None,
tool_calls: None,
role: dynamo_async_openai::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: None,
stop_reason: None,
logprobs: None,
}],
created: 0,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".to_string(),
usage: None,
nvext: None,
};

let wrapped = chat_completion_to_response(chat_resp, &ResponseParams::default()).unwrap();
assert_eq!(wrapped.inner.output.len(), 2);

match &wrapped.inner.output[0] {
OutputItem::Reasoning(reasoning) => {
assert_eq!(reasoning.summary.len(), 1);
let SummaryPart::SummaryText(summary) = &reasoning.summary[0];
assert_eq!(summary.text, "private chain of thought");
}
other => panic!("expected reasoning item, got {other:?}"),
}

match &wrapped.inner.output[1] {
OutputItem::Message(message) => {
assert_eq!(message.content.len(), 1);
match &message.content[0] {
OutputMessageContent::OutputText(text) => {
assert_eq!(text.text, "Public answer.");
assert!(!text.text.contains("<think>"));
}
_ => panic!("expected output text"),
}
}
other => panic!("expected output message, got {other:?}"),
}
}

#[test]
fn test_response_echoes_text_format() {
use dynamo_async_openai::types::responses::{
Expand Down
Loading
Loading