Skip to main content

codex_convert_proxy/convert/
util.rs

1//! Shared conversion utilities used by both streaming and non-streaming paths.
2
3use crate::constants::MAX_THINKING_BUFFER_SIZE;
4use crate::types::response_api::{OutputItemType, Tool, ToolType};
5
6use super::streaming::ResponseRequestContext;
7use memchr::memmem;
8
9/// Map a tool name to its `OutputItemType` using the original tools list.
10pub fn map_tool_name_to_output_type(
11    tool_name: &str,
12    original_tools: Option<&Vec<Tool>>,
13) -> OutputItemType {
14    if let Some(tools) = original_tools {
15        for t in tools {
16            if t.name.as_deref() == Some(tool_name) {
17                return match t.tool_type {
18                    ToolType::WebSearchPreview => OutputItemType::WebSearchCall,
19                    ToolType::FileSearch => OutputItemType::FileSearchCall,
20                    _ => OutputItemType::FunctionCall,
21                };
22            }
23        }
24    }
25    match tool_name {
26        "web_search_preview" | "web_search" => OutputItemType::WebSearchCall,
27        "file_search" => OutputItemType::FileSearchCall,
28        _ => OutputItemType::FunctionCall,
29    }
30}
31
32/// Map a tool name to its stream item type string using request context.
33pub fn map_tool_name_to_stream_item_type(
34    tool_name: &str,
35    request_context: Option<&ResponseRequestContext>,
36) -> String {
37    let tools = request_context.map(|ctx| &ctx.tools);
38    match map_tool_name_to_output_type(tool_name, tools) {
39        OutputItemType::WebSearchCall => "web_search_call".to_string(),
40        OutputItemType::FileSearchCall => "file_search_call".to_string(),
41        _ => "function_call".to_string(),
42    }
43}
44
45/// Extract query/queries from JSON arguments string.
46pub fn extract_queries_from_arguments(arguments: &str) -> Option<Vec<String>> {
47    if let Ok(value) = serde_json::from_str::<serde_json::Value>(arguments) {
48        if let Some(query) = value.get("query").and_then(|v| v.as_str()) {
49            return Some(vec![query.to_string()]);
50        }
51        if let Some(queries) = value.get("queries").and_then(|v| v.as_array()) {
52            let qs: Vec<String> = queries
53                .iter()
54                .filter_map(|q| q.as_str().map(|s| s.to_string()))
55                .collect();
56            if !qs.is_empty() {
57                return Some(qs);
58            }
59        }
60    }
61    None
62}
63
64/// Parse thinking tags from a complete text string (non-streaming).
65///
66/// Supports both `<thought>...</thought>` and `<think>...</think>` tags.
67/// Returns (actual_content, reasoning_text).
68pub fn parse_thought_tags(content: &str) -> (String, Option<String>) {
69    let mut actual_content = String::new();
70    let mut reasoning_parts: Vec<String> = Vec::new();
71    let mut remaining = content;
72
73    loop {
74        let thought_start = remaining.find("<thought>");
75        let think_start = remaining.find("<think>");
76
77        let (start_idx, open_tag, close_tag) = match (thought_start, think_start) {
78            (Some(t), Some(k)) => {
79                if t <= k {
80                    (t, "<thought>", "</thought>")
81                } else {
82                    (k, "<think>", "</think>")
83                }
84            }
85            (Some(t), None) => (t, "<thought>", "</thought>"),
86            (None, Some(k)) => (k, "<think>", "</think>"),
87            (None, None) => break,
88        };
89
90        actual_content.push_str(&remaining[..start_idx]);
91
92        let after_start = &remaining[start_idx + open_tag.len()..];
93        if let Some(end_idx) = after_start.find(close_tag) {
94            let reasoning_content = &after_start[..end_idx];
95            if !reasoning_content.is_empty() {
96                reasoning_parts.push(reasoning_content.to_string());
97            }
98            remaining = &after_start[end_idx + close_tag.len()..];
99        } else {
100            actual_content.push_str(&remaining[start_idx..]);
101            remaining = "";
102            break;
103        }
104    }
105
106    actual_content.push_str(remaining);
107
108    let reasoning = if reasoning_parts.is_empty() {
109        None
110    } else {
111        Some(reasoning_parts.join("\n\n"))
112    };
113
114    (actual_content.trim().to_string(), reasoning)
115}
116
117/// Parse thinking tags from streaming content.
118///
119/// Handles `<think>...</think>` and `<thought>...</thought>` tags that may be
120/// split across multiple chunks. Returns (actual_text, reasoning_delta, new_is_thinking).
121pub fn parse_streaming_thinking(
122    text: &str,
123    is_thinking: bool,
124    buffer: &mut String,
125) -> (String, Option<String>, bool) {
126    let mut actual_text = String::new();
127    let mut reasoning = String::new();
128    let mut current_is_thinking = is_thinking;
129
130    buffer.push_str(text);
131
132    if buffer.len() > MAX_THINKING_BUFFER_SIZE {
133        // Buffer exceeded limit - emit accumulated content and clear buffer.
134        // We always clear the buffer since content was already emitted via flushed.
135        // The is_thinking flag preserves state for next chunk (open tag still pending).
136        let flushed = buffer.clone();
137        buffer.clear();
138        return (String::new(), Some(flushed), is_thinking);
139    }
140
141    let full_content = buffer.clone();
142    buffer.clear();
143
144    let bytes = full_content.as_bytes();
145    let mut pos = 0;
146
147    while pos < bytes.len() {
148        if current_is_thinking {
149            let think_close = memmem::find(&bytes[pos..], b"</think>");
150                let thought_close = memmem::find(&bytes[pos..], b"</thought>");
151
152            match (think_close, thought_close) {
153                (Some(close_pos), Some(thought_close_pos)) => {
154                    if close_pos <= thought_close_pos {
155                        let content = std::str::from_utf8(&bytes[pos..pos + close_pos]).unwrap_or("");
156                        reasoning.push_str(content);
157                        pos += close_pos + 8;
158                        current_is_thinking = false;
159                    } else {
160                        let content = std::str::from_utf8(&bytes[pos..pos + thought_close_pos]).unwrap_or("");
161                        reasoning.push_str(content);
162                        pos += thought_close_pos + 10;
163                        current_is_thinking = false;
164                    }
165                }
166                (Some(close_pos), None) => {
167                    let content = std::str::from_utf8(&bytes[pos..pos + close_pos]).unwrap_or("");
168                    reasoning.push_str(content);
169                    pos += close_pos + 8;
170                    current_is_thinking = false;
171                }
172                (None, Some(thought_close_pos)) => {
173                    let content = std::str::from_utf8(&bytes[pos..pos + thought_close_pos]).unwrap_or("");
174                    reasoning.push_str(content);
175                    pos += thought_close_pos + 10;
176                    current_is_thinking = false;
177                }
178                (None, None) => {
179                    let remaining = std::str::from_utf8(&bytes[pos..]).unwrap_or("");
180                    buffer.push_str(remaining);
181                    break;
182                }
183            }
184        } else {
185            let think_open = memmem::find(&bytes[pos..], b"<think>");
186            let thought_open = memmem::find(&bytes[pos..], b"<thought>");
187
188            match (think_open, thought_open) {
189                (Some(open_pos), Some(thought_open_pos)) => {
190                    if open_pos <= thought_open_pos {
191                        let content = std::str::from_utf8(&bytes[pos..pos + open_pos]).unwrap_or("");
192                        actual_text.push_str(content);
193                        pos += open_pos + 7;
194                        current_is_thinking = true;
195                    } else {
196                        let content = std::str::from_utf8(&bytes[pos..pos + thought_open_pos]).unwrap_or("");
197                        actual_text.push_str(content);
198                        pos += thought_open_pos + 9;
199                        current_is_thinking = true;
200                    }
201                }
202                (Some(open_pos), None) => {
203                    let content = std::str::from_utf8(&bytes[pos..pos + open_pos]).unwrap_or("");
204                    actual_text.push_str(content);
205                    pos += open_pos + 7;
206                    current_is_thinking = true;
207                }
208                (None, Some(thought_open_pos)) => {
209                    let content = std::str::from_utf8(&bytes[pos..pos + thought_open_pos]).unwrap_or("");
210                    actual_text.push_str(content);
211                    pos += thought_open_pos + 9;
212                    current_is_thinking = true;
213                }
214                (None, None) => {
215                    let remaining = std::str::from_utf8(&bytes[pos..]).unwrap_or("");
216                    actual_text.push_str(remaining);
217                    break;
218                }
219            }
220        }
221    }
222
223    let reasoning_delta = if reasoning.is_empty() {
224        None
225    } else {
226        Some(reasoning)
227    };
228
229    (actual_text, reasoning_delta, current_is_thinking)
230}
231
232/// Find a pattern in char array starting from pos.
233pub fn find_pattern(chars: &[char], start: usize, pattern: &[char]) -> Option<usize> {
234    if start + pattern.len() > chars.len() {
235        return None;
236    }
237    for i in start..=chars.len() - pattern.len() {
238        if chars[i..i + pattern.len()] == *pattern {
239            return Some(i);
240        }
241    }
242    None
243}
244/// Escape pseudo XML tool tags that some upstream models emit as plain text.
245pub fn sanitize_pseudo_tool_markup(text: &str) -> String {
246    text.replace("<request_user_input", "&lt;request_user_input")
247        .replace("</request_user_input>", "&lt;/request_user_input&gt;")
248        .replace("<parameter ", "&lt;parameter ")
249        .replace("</parameter>", "&lt;/parameter&gt;")
250}