1use anyhow::{Context, Result};
7use serde_json::Value as JsonValue;
8
9pub mod tokens {
11 pub const BOS: &str = "<|begin▁of▁sentence|>";
12 pub const EOS: &str = "<|end▁of▁sentence|>";
13 pub const THINKING_START: &str = "<think>";
14 pub const THINKING_END: &str = "</think>";
15 pub const DSML_TOKEN: &str = "|DSML|";
16 pub const USER_START: &str = "<|User|>";
17 pub const ASSISTANT_START: &str = "<|Assistant|>";
18 pub const LATEST_REMINDER: &str = "<|latest_reminder|>";
19
20 pub const TASK_ACTION: &str = "<|action|>";
22 pub const TASK_QUERY: &str = "<|query|>";
23 pub const TASK_AUTHORITY: &str = "<|authority|>";
24 pub const TASK_DOMAIN: &str = "<|domain|>";
25 pub const TASK_TITLE: &str = "<|title|>";
26 pub const TASK_READ_URL: &str = "<|read_url|>";
27}
28
29pub(crate) const TOOL_CALLS_BLOCK_NAME: &str = "tool_calls";
30
31pub(crate) const RESPONSE_FORMAT_TEMPLATE: &str =
32 "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}";
33
34pub(crate) const TOOLS_TEMPLATE: &str = r#"## Tools
35
36You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
37
38<{dsml_token}tool_calls>
39<{dsml_token}invoke name="$TOOL_NAME">
40<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
41...
42</{dsml_token}invoke>
43<{dsml_token}invoke name="$TOOL_NAME2">
44...
45</{dsml_token}invoke>
46</{dsml_token}tool_calls>
47
48String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
49
50If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
51
52Otherwise, output directly after {thinking_end_token} with tool calls or final response.
53
54### Available Tool Schemas
55
56{tool_schemas}
57
58You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
59"#;
60
61pub(crate) const TOOLS_SYSTEM_TEMPLATE: &str = r#"## Tools
63
64You have access to a set of tools you can use to answer the user's question.
65You can invoke functions by writing a "<{dsml_token}function_calls>" block like the following as part of your reply to the user:
66<{dsml_token}function_calls>
67<{dsml_token}invoke name="$FUNCTION_NAME">
68<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
69...
70</{dsml_token}invoke>
71<{dsml_token}invoke name="$FUNCTION_NAME2">
72...
73</{dsml_token}invoke>
74</{dsml_token}function_calls>
75
76String and scalar parameters should be specified as is without any escaping or quotes, while lists and objects should use JSON format. The "string" attribute should be set to "true" for string type parameters and "false" for other types (numbers, booleans, arrays, objects).
77
78If the thinking_mode is enabled, then after function results you should strongly consider outputting a thinking block. Here is an example:
79
80<{dsml_token}function_calls>
81...
82</{dsml_token}function_calls>
83
84<function_results>
85...
86</function_results>
87
88{thinking_start_token}...thinking about results{thinking_end_token}
89
90Here are the functions available in JSONSchema format:
91<functions>
92{tool_schemas}
93</functions>
94"#;
95
96pub(crate) const REASONING_EFFORT_HIGH: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\nYou MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\nExplicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n";
97
98pub(crate) const REASONING_EFFORT_MAX: &str = "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\nYou MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\nDo not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n";
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ThinkingMode {
103 Chat,
104 Thinking,
105}
106
107impl ThinkingMode {
108 pub fn as_str(&self) -> &'static str {
109 match self {
110 ThinkingMode::Chat => "chat",
111 ThinkingMode::Thinking => "thinking",
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum ReasoningEffort {
119 Max,
120 High,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(crate) enum NormalizeNonText {
125 SerializeJson,
126 LeaveUntouched,
127}
128
129pub(crate) fn to_json(value: &JsonValue) -> String {
133 use serde::Serialize;
134 use serde_json::ser::Formatter;
135 use std::io;
136
137 struct PythonFormatter;
138
139 impl Formatter for PythonFormatter {
140 fn begin_array_value<W: ?Sized + io::Write>(
141 &mut self,
142 writer: &mut W,
143 first: bool,
144 ) -> io::Result<()> {
145 if first {
146 Ok(())
147 } else {
148 writer.write_all(b", ")
149 }
150 }
151
152 fn begin_object_key<W: ?Sized + io::Write>(
153 &mut self,
154 writer: &mut W,
155 first: bool,
156 ) -> io::Result<()> {
157 if first {
158 Ok(())
159 } else {
160 writer.write_all(b", ")
161 }
162 }
163
164 fn begin_object_value<W: ?Sized + io::Write>(&mut self, writer: &mut W) -> io::Result<()> {
165 writer.write_all(b": ")
166 }
167
168 fn write_f64<W: ?Sized + io::Write>(
169 &mut self,
170 writer: &mut W,
171 value: f64,
172 ) -> io::Result<()> {
173 writer.write_all(python_float_repr(value).as_bytes())
174 }
175 }
176
177 let mut buf = Vec::with_capacity(64);
179 let mut ser = serde_json::Serializer::with_formatter(&mut buf, PythonFormatter);
180 value
181 .serialize(&mut ser)
182 .expect("JsonValue serialization to Vec<u8> is infallible");
183 String::from_utf8(buf).expect("serde_json output is always valid UTF-8")
184}
185
186pub(crate) fn render_tools(template: &str, tools: &[JsonValue]) -> String {
187 let tools_json: Vec<String> = tools
188 .iter()
189 .filter_map(|tool| tool.get("function"))
190 .map(to_json)
191 .collect();
192
193 template
196 .replace("{dsml_token}", tokens::DSML_TOKEN)
197 .replace("{thinking_start_token}", tokens::THINKING_START)
198 .replace("{thinking_end_token}", tokens::THINKING_END)
199 .replace("{tool_schemas}", &tools_json.join("\n"))
200}
201
202pub(crate) fn find_last_user_index(messages: &[JsonValue]) -> Option<usize> {
203 messages
204 .iter()
205 .enumerate()
206 .rev()
207 .find(|(_, msg)| {
208 msg.get("role")
209 .and_then(|r| r.as_str())
210 .map(|r| r == "user" || r == "developer")
211 .unwrap_or(false)
212 })
213 .map(|(idx, _)| idx)
214}
215
216pub(crate) fn extract_visible_text(content: &JsonValue) -> String {
217 match content {
218 JsonValue::String(text) => text.clone(),
219 JsonValue::Array(items) => items
220 .iter()
221 .filter_map(|item| {
222 if let Some(text) = item.as_str() {
223 return Some(text.to_string());
224 }
225 let item_type = item.get("type").and_then(|v| v.as_str());
226 if item_type == Some("text") {
227 return item
228 .get("text")
229 .and_then(|v| v.as_str())
230 .map(|text| text.to_string());
231 }
232 tracing::warn!(
233 chunk_type = item_type.unwrap_or("unknown"),
234 "DeepSeek formatter dropped non-text content chunk while normalizing message content",
235 );
236 None
237 })
238 .collect::<String>(),
239 _ => to_json(content),
240 }
241}
242
243pub(crate) fn normalize_message_contents(messages: &mut [JsonValue], non_text: NormalizeNonText) {
244 for msg in messages {
245 let Some(content) = msg.get("content") else {
246 continue;
247 };
248 if content.is_string() {
250 continue;
251 }
252 if !content.is_array() && non_text == NormalizeNonText::LeaveUntouched {
253 continue;
254 }
255 let normalized = extract_visible_text(content);
256 if let Some(obj) = msg.as_object_mut() {
257 obj.insert("content".to_string(), JsonValue::String(normalized));
258 }
259 }
260}
261
262pub(crate) fn encode_arguments_to_dsml(tool_call: &JsonValue) -> Result<String> {
263 let arguments_str = tool_call
264 .get("arguments")
265 .and_then(|a| a.as_str())
266 .context("Missing or invalid 'arguments' field")?;
267
268 let arguments: JsonValue = match serde_json::from_str(arguments_str) {
270 Ok(v) => v,
271 Err(_) => serde_json::json!({ "arguments": arguments_str }),
272 };
273
274 let arguments_obj = arguments
275 .as_object()
276 .context("Arguments must be a JSON object")?;
277
278 let mut params = Vec::new();
279 for (key, value) in arguments_obj {
280 let value_str = if let Some(vs) = value.as_str() {
281 vs.to_string()
282 } else {
283 to_json(value)
284 };
285 params.push(format!(
286 "<{}parameter name=\"{}\" string=\"{}\">{}</{}parameter>",
287 tokens::DSML_TOKEN,
288 key,
289 if value.is_string() { "true" } else { "false" },
290 value_str,
291 tokens::DSML_TOKEN
292 ));
293 }
294
295 Ok(params.join("\n"))
296}
297
298pub(crate) fn task_token(task: &str) -> Option<&'static str> {
299 match task {
300 "action" => Some(tokens::TASK_ACTION),
301 "query" => Some(tokens::TASK_QUERY),
302 "authority" => Some(tokens::TASK_AUTHORITY),
303 "domain" => Some(tokens::TASK_DOMAIN),
304 "title" => Some(tokens::TASK_TITLE),
305 "read_url" => Some(tokens::TASK_READ_URL),
306 _ => None,
307 }
308}
309
310const USER_FIELDS_TO_PRESERVE: [&str; 3] = ["task", "wo_eos", "mask"];
311
312fn preserve_user_fields(target: &mut JsonValue, source: &JsonValue) {
313 if let Some(obj) = target.as_object_mut() {
314 for key in USER_FIELDS_TO_PRESERVE {
315 if let Some(v) = source.get(key) {
316 obj.insert(key.to_string(), v.clone());
317 }
318 }
319 }
320}
321
322pub(crate) fn merge_tool_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
327 let mut merged: Vec<JsonValue> = Vec::with_capacity(messages.len());
328
329 for mut msg in messages {
330 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
331
332 if role == "tool" {
333 let obj = msg
334 .as_object_mut()
335 .expect("a message with a role is an object");
336 let tool_block = JsonValue::Object(serde_json::Map::from_iter([
337 ("type".into(), JsonValue::String("tool_result".into())),
338 (
339 "tool_use_id".into(),
340 obj.remove("tool_call_id")
341 .unwrap_or_else(|| JsonValue::String(String::new())),
342 ),
343 (
344 "content".into(),
345 obj.remove("content")
346 .unwrap_or_else(|| JsonValue::String(String::new())),
347 ),
348 ]));
349
350 let can_merge = merged
351 .last()
352 .map(|m| {
353 m.get("role").and_then(|r| r.as_str()) == Some("user")
354 && m.get("content_blocks").is_some()
355 })
356 .unwrap_or(false);
357
358 if can_merge {
359 let last = merged.last_mut().unwrap();
360 if let Some(blocks) = last
361 .as_object_mut()
362 .and_then(|o| o.get_mut("content_blocks"))
363 .and_then(|v| v.as_array_mut())
364 {
365 blocks.push(tool_block);
366 }
367 } else {
368 merged.push(JsonValue::Object(serde_json::Map::from_iter([
369 ("role".into(), JsonValue::String("user".into())),
370 ("content_blocks".into(), JsonValue::Array(vec![tool_block])),
371 ])));
372 }
373 } else if role == "user" {
374 let text = msg
375 .as_object_mut()
376 .expect("a message with a role is an object")
377 .remove("content")
378 .filter(JsonValue::is_string)
379 .unwrap_or_else(|| JsonValue::String(String::new()));
380 let text_block = JsonValue::Object(serde_json::Map::from_iter([
381 ("type".into(), JsonValue::String("text".into())),
382 ("text".into(), text),
383 ]));
384
385 let can_merge = merged
386 .last()
387 .map(|m| {
388 m.get("role").and_then(|r| r.as_str()) == Some("user")
389 && m.get("content_blocks").is_some()
390 && m.get("task").map(|v| v.is_null()).unwrap_or(true)
391 })
392 .unwrap_or(false);
393
394 if can_merge {
395 let last = merged.last_mut().unwrap();
396 let appended = last
397 .as_object_mut()
398 .and_then(|o| o.get_mut("content_blocks"))
399 .and_then(|v| v.as_array_mut())
400 .map(|blocks| {
401 blocks.push(text_block);
402 })
403 .is_some();
404 if appended {
405 preserve_user_fields(last, &msg);
406 }
407 } else {
408 let mut new_msg = JsonValue::Object(serde_json::Map::from_iter([
411 ("role".into(), JsonValue::String("user".into())),
412 ("content_blocks".into(), JsonValue::Array(vec![text_block])),
413 ]));
414 preserve_user_fields(&mut new_msg, &msg);
415 merged.push(new_msg);
416 }
417 } else {
418 merged.push(msg);
419 }
420 }
421
422 merged
423}
424
425pub(crate) fn sort_tool_results_by_call_order(mut messages: Vec<JsonValue>) -> Vec<JsonValue> {
428 use std::collections::HashMap;
429 let mut last_order: HashMap<String, usize> = HashMap::new();
430
431 for msg in &mut messages {
432 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
433 if role == "assistant" {
434 if let Some(tcs) = msg.get("tool_calls").and_then(|t| t.as_array()) {
435 last_order.clear();
436 for (idx, tc) in tcs.iter().enumerate() {
437 let id = tc
438 .get("id")
439 .and_then(|v| v.as_str())
440 .or_else(|| {
441 tc.get("function")
442 .and_then(|f| f.get("id"))
443 .and_then(|v| v.as_str())
444 })
445 .unwrap_or("");
446 if !id.is_empty() {
447 last_order.insert(id.to_string(), idx);
448 }
449 }
450 }
451 } else if role == "user" && !last_order.is_empty() {
452 let Some(blocks) = msg
453 .as_object_mut()
454 .and_then(|o| o.get_mut("content_blocks"))
455 .and_then(|v| v.as_array_mut())
456 else {
457 continue;
458 };
459
460 let tool_positions: Vec<usize> = blocks
462 .iter()
463 .enumerate()
464 .filter(|(_, b)| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
465 .map(|(i, _)| i)
466 .collect();
467
468 if tool_positions.len() > 1 {
469 let start = *tool_positions
470 .first()
471 .expect("tool_positions has length > 1");
472 let end = *tool_positions
473 .last()
474 .expect("tool_positions has length > 1");
475 let is_contiguous = end - start + 1 == tool_positions.len();
476
477 if is_contiguous {
478 blocks[start..=end].sort_by_key(|b| {
480 let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
481 *last_order.get(id).unwrap_or(&0)
482 });
483 } else {
484 let mut tool_blocks: Vec<JsonValue> = tool_positions
486 .iter()
487 .map(|&i| std::mem::take(&mut blocks[i]))
488 .collect();
489
490 tool_blocks.sort_by_key(|b| {
491 let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
492 *last_order.get(id).unwrap_or(&0)
493 });
494
495 for (sorted_idx, &pos) in tool_positions.iter().enumerate() {
496 blocks[pos] = std::mem::take(&mut tool_blocks[sorted_idx]);
497 }
498 }
499 }
500 }
501 }
502
503 messages
504}
505
506pub(crate) fn drop_thinking_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
508 let last_user_idx = find_last_user_index(&messages);
509 let mut out = Vec::with_capacity(messages.len());
510 const KEEP: &[&str] = &[
511 "user",
512 "system",
513 "tool",
514 "latest_reminder",
515 "direct_search_results",
516 ];
517
518 for (idx, mut msg) in messages.into_iter().enumerate() {
519 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
520 if KEEP.contains(&role) || last_user_idx.is_none_or(|u| idx >= u) {
521 out.push(msg);
522 } else if role == "assistant" {
523 if let Some(obj) = msg.as_object_mut() {
524 obj.remove("reasoning_content");
525 }
526 out.push(msg);
527 }
528 }
530 out
531}
532
533pub(crate) fn resolve_thinking_mode(
534 args: Option<&std::collections::HashMap<String, serde_json::Value>>,
535 default_mode: ThinkingMode,
536) -> ThinkingMode {
537 if let Some(enabled) = crate::thinking_bool_from_args(args) {
538 return if enabled {
539 ThinkingMode::Thinking
540 } else {
541 ThinkingMode::Chat
542 };
543 }
544 if let Some(args) = args
545 && let Some(mode) = args.get("thinking_mode").and_then(|v| v.as_str())
546 {
547 match mode {
548 "chat" => return ThinkingMode::Chat,
549 "thinking" => return ThinkingMode::Thinking,
550 _ => {}
551 }
552 }
553 default_mode
554}
555
556pub(crate) fn inject_tools_and_response_format(
557 messages_array: &mut Vec<JsonValue>,
558 req: &dyn crate::OAIChatLikeRequest,
559) -> Result<()> {
560 let tools_json = req
561 .tools()
562 .map(|t| serde_json::to_value(&t))
563 .transpose()
564 .context("Failed to convert tools to JSON")?;
565
566 let tools_json = match req.tool_choice() {
572 Some(ref tc) if tc.as_str() == Some("none") => None,
573 _ => tools_json,
574 };
575
576 let response_format_json = req
577 .response_format()
578 .map(|rf| serde_json::to_value(&rf))
579 .transpose()
580 .context("Failed to convert response_format to JSON")?;
581
582 if tools_json.is_some() || response_format_json.is_some() {
583 let system_idx = messages_array
584 .iter()
585 .position(|msg| msg.get("role").and_then(|r| r.as_str()) == Some("system"));
586
587 if let Some(idx) = system_idx {
588 if let Some(msg) = messages_array.get_mut(idx)
589 && let Some(obj) = msg.as_object_mut()
590 {
591 if let Some(tools) = tools_json {
592 obj.insert("tools".to_string(), tools);
593 }
594 if let Some(rf) = response_format_json {
595 obj.insert("response_format".to_string(), rf);
596 }
597 }
598 } else {
599 let mut system_msg = serde_json::json!({
600 "role": "system",
601 "content": ""
602 });
603 if let Some(obj) = system_msg.as_object_mut() {
604 if let Some(tools) = tools_json {
605 obj.insert("tools".to_string(), tools);
606 }
607 if let Some(rf) = response_format_json {
608 obj.insert("response_format".to_string(), rf);
609 }
610 }
611 messages_array.insert(0, system_msg);
612 }
613 }
614 Ok(())
615}
616
617fn python_float_repr(value: f64) -> String {
621 let shortest = serde_json::Number::from_f64(value)
624 .expect("JSON floats are finite")
625 .to_string();
626 let (sign, unsigned) = match shortest.strip_prefix('-') {
627 Some(unsigned) => ("-", unsigned),
628 None => ("", shortest.as_str()),
629 };
630 let (mantissa, exponent) = match unsigned.split_once('e') {
631 Some((mantissa, exponent)) => (mantissa, exponent.parse().expect("ryu exponent")),
632 None => (unsigned, 0),
633 };
634 let (integer, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
635 let all_digits = format!("{integer}{fraction}");
636 let significant = all_digits.trim_start_matches('0');
637 let digits = significant.trim_end_matches('0');
638 if digits.is_empty() {
639 return format!("{sign}0.0");
640 }
641 let leading_zeros = (all_digits.len() - significant.len()) as i32;
642 let exponent: i32 = exponent + integer.len() as i32 - 1 - leading_zeros;
643 if (-4..16).contains(&exponent) {
644 let point = exponent + 1;
645 let fixed = if point <= 0 {
646 format!("0.{}{digits}", "0".repeat(point.unsigned_abs() as usize))
647 } else if point as usize >= digits.len() {
648 format!("{digits}{}.0", "0".repeat(point as usize - digits.len()))
649 } else {
650 format!(
651 "{}.{}",
652 &digits[..point as usize],
653 &digits[point as usize..]
654 )
655 };
656 format!("{sign}{fixed}")
657 } else {
658 let (head, tail) = digits.split_at(1);
659 let mantissa = if tail.is_empty() {
660 head.to_string()
661 } else {
662 format!("{head}.{tail}")
663 };
664 let exponent_sign = if exponent < 0 { '-' } else { '+' };
665 format!(
666 "{sign}{mantissa}e{exponent_sign}{:02}",
667 exponent.unsigned_abs()
668 )
669 }
670}
671
672#[cfg(test)]
673mod tests {
674
675 #[test]
676 fn floats_match_python_json_dumps() {
677 for (value, python) in [
678 (0.000001, "1e-06"),
679 (0.0001, "0.0001"),
680 (0.00001234, "1.234e-05"),
681 (1e16, "1e+16"),
682 (1e15, "1000000000000000.0"),
683 (1.5e-7, "1.5e-07"),
684 (2.5, "2.5"),
685 (100.0, "100.0"),
686 (0.1, "0.1"),
687 (-0.0, "-0.0"),
688 (0.0, "0.0"),
689 (-123.456, "-123.456"),
690 (1.7976931348623157e308, "1.7976931348623157e+308"),
691 (5e-324, "5e-324"),
692 (1e15 + 0.25, "1000000000000000.2"),
694 (1e14 + 0.125, "100000000000000.12"),
695 ] {
696 assert_eq!(python_float_repr(value), python, "{value:?}");
697 }
698 let value = serde_json::json!({"a": 0.000001, "b": [1e16, 3]});
699 assert_eq!(to_json(&value), r#"{"a": 1e-06, "b": [1e+16, 3]}"#);
700 }
701
702 use super::*;
703 use serde_json::json;
704
705 #[test]
706 fn merged_user_blocks_preserve_metadata_and_order() {
707 let merged = merge_tool_messages(vec![
708 serde_json::json!({"role": "tool", "tool_call_id": "c1", "content": "result"}),
709 serde_json::json!({"role": "user", "content": "one", "wo_eos": true, "mask": [1, 0]}),
710 serde_json::json!({"role": "user", "content": "two", "task": "action"}),
711 serde_json::json!({"role": "user", "content": "separate"}),
712 ]);
713 assert_eq!(merged.len(), 2);
714 assert!(
715 merged
716 .iter()
717 .all(|message| message.get("content").is_none())
718 );
719 assert_eq!(
720 merged[0]["content_blocks"],
721 serde_json::json!([
722 {"type": "tool_result", "tool_use_id": "c1", "content": "result"},
723 {"type": "text", "text": "one"},
724 {"type": "text", "text": "two"}
725 ])
726 );
727 assert_eq!(merged[0]["task"], "action");
728 assert_eq!(merged[0]["wo_eos"], true);
729 assert_eq!(merged[0]["mask"], serde_json::json!([1, 0]));
730 assert_eq!(merged[1]["content_blocks"][0]["text"], "separate");
731 }
732
733 #[test]
734 fn test_extract_visible_text_from_content_array() {
735 let content = json!([
736 {"type": "text", "text": "who "},
737 {"type": "text", "text": "are "},
738 {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}},
739 {"type": "text", "text": "you?"}
740 ]);
741 assert_eq!(extract_visible_text(&content), "who are you?");
742 }
743
744 #[test]
745 fn test_render_tools_preserves_placeholder_text_inside_tool_schema() {
746 let tools = json!([{
747 "type": "function",
748 "function": {
749 "name": "placeholder_tool",
750 "description": "literal {dsml_token} {thinking_start_token} {thinking_end_token}",
751 "parameters": {"type": "object", "properties": {}}
752 }
753 }]);
754 let rendered = render_tools(
755 "static {dsml_token} {thinking_start_token} {thinking_end_token}\n{tool_schemas}",
756 tools.as_array().unwrap(),
757 );
758
759 assert!(rendered.starts_with(&format!(
760 "static {} {} {}\n",
761 tokens::DSML_TOKEN,
762 tokens::THINKING_START,
763 tokens::THINKING_END
764 )));
765 assert!(
766 rendered.contains("literal {dsml_token} {thinking_start_token} {thinking_end_token}")
767 );
768 }
769}