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 if let Some(blocks) = merged
397 .last_mut()
398 .and_then(|last| last.get_mut("content_blocks"))
399 .and_then(|v| v.as_array_mut())
400 {
401 blocks.push(text_block);
402 }
403 } else {
404 let mut new_msg = JsonValue::Object(serde_json::Map::from_iter([
407 ("role".into(), JsonValue::String("user".into())),
408 ("content_blocks".into(), JsonValue::Array(vec![text_block])),
409 ]));
410 preserve_user_fields(&mut new_msg, &msg);
411 merged.push(new_msg);
412 }
413 } else {
414 merged.push(msg);
415 }
416 }
417
418 merged
419}
420
421pub(crate) fn sort_tool_results_by_call_order(mut messages: Vec<JsonValue>) -> Vec<JsonValue> {
424 use std::collections::HashMap;
425 let mut last_order: HashMap<String, usize> = HashMap::new();
426
427 for msg in &mut messages {
428 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
429 if role == "assistant" {
430 if let Some(tcs) = msg
432 .get("tool_calls")
433 .and_then(|t| t.as_array())
434 .filter(|t| !t.is_empty())
435 {
436 last_order.clear();
437 for (idx, tc) in tcs.iter().enumerate() {
438 let id = tc
439 .get("id")
440 .and_then(|v| v.as_str())
441 .or_else(|| {
442 tc.get("function")
443 .and_then(|f| f.get("id"))
444 .and_then(|v| v.as_str())
445 })
446 .unwrap_or("");
447 if !id.is_empty() {
448 last_order.insert(id.to_string(), idx);
449 }
450 }
451 }
452 } else if role == "user" && !last_order.is_empty() {
453 let Some(blocks) = msg
454 .as_object_mut()
455 .and_then(|o| o.get_mut("content_blocks"))
456 .and_then(|v| v.as_array_mut())
457 else {
458 continue;
459 };
460
461 let tool_positions: Vec<usize> = blocks
463 .iter()
464 .enumerate()
465 .filter(|(_, b)| b.get("type").and_then(|v| v.as_str()) == Some("tool_result"))
466 .map(|(i, _)| i)
467 .collect();
468
469 if tool_positions.len() > 1 {
470 let start = *tool_positions
471 .first()
472 .expect("tool_positions has length > 1");
473 let end = *tool_positions
474 .last()
475 .expect("tool_positions has length > 1");
476 let is_contiguous = end - start + 1 == tool_positions.len();
477
478 if is_contiguous {
479 blocks[start..=end].sort_by_key(|b| {
481 let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
482 *last_order.get(id).unwrap_or(&0)
483 });
484 } else {
485 let mut tool_blocks: Vec<JsonValue> = tool_positions
487 .iter()
488 .map(|&i| std::mem::take(&mut blocks[i]))
489 .collect();
490
491 tool_blocks.sort_by_key(|b| {
492 let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
493 *last_order.get(id).unwrap_or(&0)
494 });
495
496 for (sorted_idx, &pos) in tool_positions.iter().enumerate() {
497 blocks[pos] = std::mem::take(&mut tool_blocks[sorted_idx]);
498 }
499 }
500 }
501 }
502 }
503
504 messages
505}
506
507pub(crate) fn drop_thinking_messages(messages: Vec<JsonValue>) -> Vec<JsonValue> {
509 let last_user_idx = find_last_user_index(&messages);
510 let mut out = Vec::with_capacity(messages.len());
511 const KEEP: &[&str] = &[
512 "user",
513 "system",
514 "tool",
515 "latest_reminder",
516 "direct_search_results",
517 ];
518
519 for (idx, mut msg) in messages.into_iter().enumerate() {
520 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
521 if KEEP.contains(&role) || last_user_idx.is_none_or(|u| idx >= u) {
522 out.push(msg);
523 } else if role == "assistant" {
524 if let Some(obj) = msg.as_object_mut() {
525 obj.remove("reasoning_content");
526 }
527 out.push(msg);
528 }
529 }
531 out
532}
533
534pub(crate) fn resolve_thinking_mode(
535 args: Option<&std::collections::HashMap<String, serde_json::Value>>,
536 default_mode: ThinkingMode,
537) -> ThinkingMode {
538 if let Some(enabled) = crate::thinking_bool_from_args(args) {
539 return if enabled {
540 ThinkingMode::Thinking
541 } else {
542 ThinkingMode::Chat
543 };
544 }
545 if let Some(args) = args
546 && let Some(mode) = args.get("thinking_mode").and_then(|v| v.as_str())
547 {
548 match mode {
549 "chat" => return ThinkingMode::Chat,
550 "thinking" => return ThinkingMode::Thinking,
551 _ => {}
552 }
553 }
554 default_mode
555}
556
557pub(crate) fn inject_tools_and_response_format(
558 messages_array: &mut Vec<JsonValue>,
559 req: &dyn crate::OAIChatLikeRequest,
560) -> Result<()> {
561 let tools_json = req
562 .tools()
563 .map(|t| serde_json::to_value(&t))
564 .transpose()
565 .context("Failed to convert tools to JSON")?;
566
567 let tools_json = match req.tool_choice() {
573 Some(ref tc) if tc.as_str() == Some("none") => None,
574 _ => tools_json,
575 };
576
577 let response_format_json = req
578 .response_format()
579 .map(|rf| serde_json::to_value(&rf))
580 .transpose()
581 .context("Failed to convert response_format to JSON")?;
582
583 if tools_json.is_some() || response_format_json.is_some() {
584 let system_idx = messages_array
585 .iter()
586 .position(|msg| msg.get("role").and_then(|r| r.as_str()) == Some("system"));
587
588 if let Some(idx) = system_idx {
589 if let Some(msg) = messages_array.get_mut(idx)
590 && let Some(obj) = msg.as_object_mut()
591 {
592 if let Some(tools) = tools_json {
593 obj.insert("tools".to_string(), tools);
594 }
595 if let Some(rf) = response_format_json {
596 obj.insert("response_format".to_string(), rf);
597 }
598 }
599 } else {
600 let mut system_msg = serde_json::json!({
601 "role": "system",
602 "content": ""
603 });
604 if let Some(obj) = system_msg.as_object_mut() {
605 if let Some(tools) = tools_json {
606 obj.insert("tools".to_string(), tools);
607 }
608 if let Some(rf) = response_format_json {
609 obj.insert("response_format".to_string(), rf);
610 }
611 }
612 messages_array.insert(0, system_msg);
613 }
614 }
615 Ok(())
616}
617
618fn python_float_repr(value: f64) -> String {
622 let shortest = serde_json::Number::from_f64(value)
625 .expect("JSON floats are finite")
626 .to_string();
627 let (sign, unsigned) = match shortest.strip_prefix('-') {
628 Some(unsigned) => ("-", unsigned),
629 None => ("", shortest.as_str()),
630 };
631 let (mantissa, exponent) = match unsigned.split_once('e') {
632 Some((mantissa, exponent)) => (mantissa, exponent.parse().expect("ryu exponent")),
633 None => (unsigned, 0),
634 };
635 let (integer, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
636 let all_digits = format!("{integer}{fraction}");
637 let significant = all_digits.trim_start_matches('0');
638 let digits = significant.trim_end_matches('0');
639 if digits.is_empty() {
640 return format!("{sign}0.0");
641 }
642 let leading_zeros = (all_digits.len() - significant.len()) as i32;
643 let exponent: i32 = exponent + integer.len() as i32 - 1 - leading_zeros;
644 if (-4..16).contains(&exponent) {
645 let point = exponent + 1;
646 let fixed = if point <= 0 {
647 format!("0.{}{digits}", "0".repeat(point.unsigned_abs() as usize))
648 } else if point as usize >= digits.len() {
649 format!("{digits}{}.0", "0".repeat(point as usize - digits.len()))
650 } else {
651 format!(
652 "{}.{}",
653 &digits[..point as usize],
654 &digits[point as usize..]
655 )
656 };
657 format!("{sign}{fixed}")
658 } else {
659 let (head, tail) = digits.split_at(1);
660 let mantissa = if tail.is_empty() {
661 head.to_string()
662 } else {
663 format!("{head}.{tail}")
664 };
665 let exponent_sign = if exponent < 0 { '-' } else { '+' };
666 format!(
667 "{sign}{mantissa}e{exponent_sign}{:02}",
668 exponent.unsigned_abs()
669 )
670 }
671}
672
673#[cfg(test)]
674mod tests {
675
676 #[test]
677 fn floats_match_python_json_dumps() {
678 for (value, python) in [
679 (0.000001, "1e-06"),
680 (0.0001, "0.0001"),
681 (0.00001234, "1.234e-05"),
682 (1e16, "1e+16"),
683 (1e15, "1000000000000000.0"),
684 (1.5e-7, "1.5e-07"),
685 (2.5, "2.5"),
686 (100.0, "100.0"),
687 (0.1, "0.1"),
688 (-0.0, "-0.0"),
689 (0.0, "0.0"),
690 (-123.456, "-123.456"),
691 (1.7976931348623157e308, "1.7976931348623157e+308"),
692 (5e-324, "5e-324"),
693 (1e15 + 0.25, "1000000000000000.2"),
695 (1e14 + 0.125, "100000000000000.12"),
696 ] {
697 assert_eq!(python_float_repr(value), python, "{value:?}");
698 }
699 let value = serde_json::json!({"a": 0.000001, "b": [1e16, 3]});
700 assert_eq!(to_json(&value), r#"{"a": 1e-06, "b": [1e+16, 3]}"#);
701 }
702
703 use super::*;
704 use serde_json::json;
705
706 #[test]
707 fn merged_users_drop_their_metadata_like_the_reference() {
708 let merged = merge_tool_messages(vec![
711 json!({"role": "tool", "tool_call_id": "c1", "content": "result"}),
712 json!({"role": "user", "content": "one", "wo_eos": true, "mask": [1, 0]}),
713 json!({"role": "user", "content": "two", "task": "action"}),
714 json!({"role": "assistant", "content": "a"}),
715 json!({"role": "user", "content": "three", "task": "action"}),
716 json!({"role": "user", "content": "four", "wo_eos": true}),
717 ]);
718 assert_eq!(
719 merged,
720 [
721 json!({"role": "user", "content_blocks": [
722 {"type": "tool_result", "tool_use_id": "c1", "content": "result"},
723 {"type": "text", "text": "one"},
724 {"type": "text", "text": "two"}
725 ]}),
726 json!({"role": "assistant", "content": "a"}),
727 json!({"role": "user", "content_blocks": [{"type": "text", "text": "three"}], "task": "action"}),
728 json!({"role": "user", "content_blocks": [{"type": "text", "text": "four"}], "wo_eos": true}),
729 ]
730 );
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}