Skip to main content

vtcode_core/llm/
utils.rs

1//! Shared utilities for LLM request/response processing
2//!
3//! This module provides common functions used across multiple providers
4//! to eliminate duplicate code and reduce allocations.
5
6use anyhow::{Context, Result};
7use serde_json::Value;
8
9use crate::llm::provider::{LLMRequest, LLMResponse, LLMStreamEvent, ToolCall};
10
11/// Parse chat request from OpenAI-compatible format
12pub fn parse_chat_request_openai_format(value: &Value, default_model: &str) -> Option<LLMRequest> {
13    crate::llm::providers::common::parse_chat_request_openai_format(value, default_model)
14}
15
16/// Parse response from OpenAI-compatible format
17pub fn parse_response_openai_format(
18    response: Value,
19    _provider_name: &str,
20    model: String,
21    include_cache: bool,
22    reasoning_content: Option<String>,
23) -> Result<LLMResponse> {
24    let choices = response
25        .get("choices")
26        .context("Missing choices in response")?
27        .as_array()
28        .context("Choices must be an array")?;
29
30    if choices.is_empty() {
31        anyhow::bail!("No choices in response");
32    }
33
34    let first_choice = &choices[0];
35    let message = first_choice
36        .get("message")
37        .context("Missing message in choice")?;
38
39    let content = message
40        .get("content")
41        .and_then(|c| c.as_str())
42        .unwrap_or("")
43        .to_string();
44
45    // Extract usage information
46    let usage = response.get("usage");
47    let input_tokens = usage
48        .and_then(|u| u.get("prompt_tokens"))
49        .and_then(|t| t.as_u64())
50        .unwrap_or(0);
51
52    let output_tokens = usage
53        .and_then(|u| u.get("completion_tokens"))
54        .and_then(|t| t.as_u64())
55        .unwrap_or(0);
56
57    // Extract function call if present
58    let tool_call = message.get("function_call").and_then(|fc| {
59        let name = fc.get("name")?.as_str()?;
60        let arguments = fc.get("arguments")?.as_str()?;
61
62        Some(ToolCall::function(
63            "call_001".to_string(), // Generate a simple ID
64            name.to_string(),
65            arguments.to_string(),
66        ))
67    });
68
69    // Use the provider's LLMResponse which has different structure
70    let mut llm_response = LLMResponse {
71        content: None,
72        tool_calls: None,
73        model,
74        usage: Some(crate::llm::provider::Usage {
75            prompt_tokens: u32::try_from(input_tokens).unwrap_or(u32::MAX),
76            completion_tokens: u32::try_from(output_tokens).unwrap_or(u32::MAX),
77            total_tokens: u32::try_from(input_tokens.saturating_add(output_tokens))
78                .unwrap_or(u32::MAX),
79            cached_prompt_tokens: if include_cache {
80                response
81                    .get("cache_hit")
82                    .and_then(|c| c.as_bool())
83                    .map(|_| 0)
84            } else {
85                None
86            },
87            cache_creation_tokens: None,
88            cache_read_tokens: None,
89            iterations: None,
90        }),
91        finish_reason: crate::llm::provider::FinishReason::Stop,
92        reasoning: reasoning_content,
93        reasoning_details: None,
94        tool_references: Vec::new(),
95        request_id: None,
96        organization_id: None,
97        compaction: None,
98    };
99
100    // Set content based on function call or regular content
101    if let Some(tool_call) = tool_call {
102        llm_response.content = None;
103        llm_response.tool_calls = Some(vec![tool_call]);
104    } else {
105        llm_response.content = Some(content);
106        llm_response.tool_calls = None;
107    }
108
109    Ok(llm_response)
110}
111
112/// Parse stream event from OpenAI-compatible format
113pub fn parse_stream_event_openai_format(
114    json: Value,
115    _provider_name: &str,
116) -> Option<LLMStreamEvent> {
117    let choices = json.get("choices")?.as_array()?;
118    if choices.is_empty() {
119        return None;
120    }
121
122    let delta = choices[0].get("delta")?;
123    let content = delta.get("content").and_then(|c| c.as_str())?;
124
125    Some(LLMStreamEvent::Token {
126        delta: content.to_string(),
127    })
128}
129
130/// Extract reasoning content from text (for providers that support reasoning)
131///
132/// Supports the following reasoning tag patterns:
133/// - <think></think>
134/// - <thought></thought>
135/// - <reasoning></reasoning>
136/// - <analysis></analysis>
137/// - <thinking></thinking>
138///
139/// Returns (reasoning_parts, cleaned_content) where reasoning_parts contains
140/// the extracted reasoning text (without tags) and cleaned_content is the
141/// remaining content with reasoning sections removed.
142pub fn extract_reasoning_content(content: &str) -> (Vec<String>, Option<String>) {
143    if let Some((deprecated_reasoning, deprecated_content)) =
144        extract_deprecated_reasoning_sections(content)
145    {
146        let reasoning_parts = deprecated_reasoning
147            .map(|value| vec![value])
148            .unwrap_or_default();
149        return (reasoning_parts, deprecated_content);
150    }
151
152    // Use the robust split_reasoning_from_text function that handles all tag types
153    let (segments, cleaned_content) = crate::llm::providers::split_reasoning_from_text(content);
154
155    let reasoning_parts: Vec<String> = segments.into_iter().map(|s| s.text).collect();
156
157    let final_content = if let Some(cleaned) = cleaned_content {
158        let trimmed = cleaned.trim();
159        if trimmed.is_empty() {
160            None
161        } else {
162            Some(trimmed.to_string())
163        }
164    } else {
165        None
166    };
167
168    (reasoning_parts, final_content)
169}
170
171fn extract_deprecated_reasoning_sections(
172    content: &str,
173) -> Option<(Option<String>, Option<String>)> {
174    let mut reasoning_lines: Vec<String> = Vec::new();
175    let mut content_lines: Vec<String> = Vec::new();
176    let mut active_section: Option<&str> = None;
177    let mut saw_reasoning = false;
178    let mut saw_content = false;
179    let mut saw_first_key = false;
180
181    for line in content.lines() {
182        let trimmed = line.trim_end();
183        let trimmed_start = trimmed.trim_start();
184
185        if trimmed_start.is_empty() {
186            if let Some(section) = active_section {
187                match section {
188                    "reasoning" => reasoning_lines.push(String::new()),
189                    "content" => content_lines.push(String::new()),
190                    _ => {}
191                }
192            }
193            continue;
194        }
195
196        if let Some(rest) = trimmed_start.strip_prefix("reasoning:") {
197            saw_first_key = true;
198            saw_reasoning = true;
199            active_section = Some("reasoning");
200            let value = rest.trim_start();
201            if !matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+") && !value.is_empty() {
202                reasoning_lines.push(value.to_string());
203            }
204            continue;
205        }
206
207        if let Some(rest) = trimmed_start.strip_prefix("content:") {
208            saw_first_key = true;
209            saw_content = true;
210            active_section = Some("content");
211            let value = rest.trim_start();
212            if !matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+") && !value.is_empty() {
213                content_lines.push(value.to_string());
214            }
215            continue;
216        }
217
218        if !saw_first_key {
219            return None;
220        }
221
222        if let Some(section) = active_section {
223            match section {
224                "reasoning" => reasoning_lines.push(trimmed_start.to_string()),
225                "content" => content_lines.push(trimmed_start.to_string()),
226                _ => {}
227            }
228        }
229    }
230
231    if !(saw_reasoning && saw_content) {
232        return None;
233    }
234
235    let reasoning = join_deprecated_section(&reasoning_lines);
236    let content = join_deprecated_section(&content_lines);
237
238    if reasoning.is_none() && content.is_none() {
239        None
240    } else {
241        Some((reasoning, content))
242    }
243}
244
245fn join_deprecated_section(lines: &[String]) -> Option<String> {
246    if lines.is_empty() {
247        return None;
248    }
249
250    let joined = lines.join("\n");
251    let trimmed = joined.trim();
252    if trimmed.is_empty() {
253        None
254    } else {
255        Some(trimmed.to_string())
256    }
257}
258
259pub use vtcode_commons::tokens::{
260    estimate_tokens as estimate_token_count, truncate_to_tokens as truncate_to_token_limit,
261};
262
263/// Create a consistent error message for LLM errors
264pub fn format_llm_error(provider_name: &str, error_message: &str) -> String {
265    format!("[{}] {}", provider_name, error_message.trim())
266}
267
268/// Validate that a model string is not empty and reasonable
269pub fn validate_model_string(model: &str) -> Result<()> {
270    if model.is_empty() {
271        anyhow::bail!("Model cannot be empty")
272    }
273
274    if model.len() > 100 {
275        anyhow::bail!("Model name too long (max 100 characters)")
276    }
277
278    // Basic sanity check for model name format
279    if !model
280        .chars()
281        .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == ':')
282    {
283        anyhow::bail!("Model contains invalid characters. Only alphanumeric, -, _, ., : allowed")
284    }
285
286    Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::llm::provider::{AssistantPhase, MessageRole};
293
294    #[test]
295    fn test_parse_chat_request_openai_format() {
296        let json = serde_json::json!({
297            "model": "gpt-5",
298            "messages": [
299                {"role": "user", "content": "Hello"},
300                {"role": "assistant", "content": "Hi there", "phase": "commentary"}
301            ],
302            "temperature": 0.7,
303            "max_tokens": 100
304        });
305
306        let request = parse_chat_request_openai_format(&json, "default-model").unwrap();
307        assert_eq!(request.model, "gpt-5");
308        assert_eq!(request.messages.len(), 2);
309        assert_eq!(request.messages[0].role, MessageRole::User);
310        assert_eq!(request.messages[0].content.as_text(), "Hello");
311        assert_eq!(request.messages[0].phase, None);
312        assert_eq!(request.messages[1].phase, Some(AssistantPhase::Commentary));
313        assert_eq!(request.temperature, Some(0.7));
314        assert_eq!(request.max_tokens, Some(100));
315    }
316
317    #[test]
318    fn test_parse_chat_request_openai_format_ignores_phase_for_non_assistant_roles() {
319        let json = serde_json::json!({
320            "messages": [
321                {"role": "user", "content": "Hello", "phase": "commentary"},
322                {"role": "tool", "content": "{}", "tool_call_id": "call_1", "phase": "final_answer"}
323            ]
324        });
325
326        let request = parse_chat_request_openai_format(&json, "default-model").unwrap();
327        assert_eq!(request.messages[0].phase, None);
328        assert_eq!(request.messages[1].phase, None);
329    }
330
331    #[test]
332    fn test_parse_response_openai_format() {
333        let response = serde_json::json!({
334            "choices": [{
335                "message": {
336                    "content": "Hello world",
337                    "role": "assistant"
338                }
339            }],
340            "usage": {
341                "prompt_tokens": 10,
342                "completion_tokens": 5
343            },
344            "model": "gpt-5"
345        });
346
347        let result =
348            parse_response_openai_format(response, "test", "gpt-5".to_string(), false, None)
349                .unwrap();
350        assert_eq!(result.content_text(), "Hello world");
351        let usage = result.usage.expect("usage should be present");
352        assert_eq!(usage.prompt_tokens, 10);
353        assert_eq!(usage.completion_tokens, 5);
354    }
355
356    #[test]
357    fn test_extract_reasoning_content() {
358        let content = "Some text <reasoning>This is reasoning</reasoning> More text";
359        let (reasoning, main) = extract_reasoning_content(content);
360
361        assert_eq!(reasoning.len(), 1);
362        assert_eq!(reasoning[0], "This is reasoning");
363        assert_eq!(main.unwrap(), "Some text  More text");
364    }
365
366    #[test]
367    fn test_extract_reasoning_content_deprecated_format() {
368        let content = "reasoning: Need to run cargo clippy.\ncontent: Need to run cargo clippy.";
369        let (reasoning, main) = extract_reasoning_content(content);
370
371        assert_eq!(reasoning.len(), 1);
372        assert_eq!(reasoning[0], "Need to run cargo clippy.");
373        assert_eq!(main.as_deref(), Some("Need to run cargo clippy."));
374    }
375
376    #[test]
377    fn test_extract_reasoning_content_think_tags() {
378        let content = "Let me think <think>I need to analyze this problem</think>The answer is 42";
379        let (reasoning, main) = extract_reasoning_content(content);
380
381        assert_eq!(reasoning.len(), 1);
382        assert_eq!(reasoning[0], "I need to analyze this problem");
383        assert_eq!(main.unwrap(), "Let me think The answer is 42");
384    }
385
386    #[test]
387    fn test_extract_reasoning_content_analysis_tags() {
388        let content = "<analysis>Breaking down the requirements</analysis>Here is the solution";
389        let (reasoning, main) = extract_reasoning_content(content);
390
391        assert_eq!(reasoning.len(), 1);
392        assert_eq!(reasoning[0], "Breaking down the requirements");
393        assert_eq!(main.unwrap(), "Here is the solution");
394    }
395
396    #[test]
397    fn test_extract_reasoning_content_thinking_tags() {
398        let content = "<thinking>First, I'll check the dependencies</thinking>Now implementing";
399        let (reasoning, main) = extract_reasoning_content(content);
400
401        assert_eq!(reasoning.len(), 1);
402        assert_eq!(reasoning[0], "First, I'll check the dependencies");
403        assert_eq!(main.unwrap(), "Now implementing");
404    }
405
406    #[test]
407    fn test_extract_reasoning_content_multiple_tags() {
408        // Multiple reasoning sections with different tag types
409        let content = "<think>Step 1: Plan</think> text <analysis>Step 2: Analyze</think> end";
410        let (reasoning, main) = extract_reasoning_content(content);
411
412        // Note: Current implementation may merge adjacent reasoning segments
413        // Testing that at least one reasoning section is extracted
414        assert!(!reasoning.is_empty());
415        assert!(
416            reasoning
417                .iter()
418                .any(|r| r.contains("Step 1") || r.contains("Step 2"))
419        );
420        let main_text = main.unwrap();
421        assert!(main_text.contains("text") || main_text.contains("end"));
422    }
423
424    #[test]
425    fn test_estimate_token_count() {
426        assert_eq!(estimate_token_count("Hello world"), 3); // ~11 chars / 4
427        assert_eq!(estimate_token_count(""), 0); // empty input
428        assert_eq!(estimate_token_count("a"), 1); // minimum 1
429    }
430
431    #[test]
432    fn test_truncate_to_token_limit() {
433        let text = "Hello world this is a longer text that should be truncated";
434        let truncated = truncate_to_token_limit(text, 3);
435        assert!(truncated.len() < text.len());
436        assert!(!truncated.contains("truncated"));
437    }
438
439    #[test]
440    fn test_format_llm_error() {
441        let error = format_llm_error("OpenAI", "Rate limit exceeded");
442        assert_eq!(error, "[OpenAI] Rate limit exceeded");
443    }
444
445    #[test]
446    fn test_validate_model_string() {
447        validate_model_string("gpt-5").unwrap();
448        validate_model_string("claude-haiku-4-5").unwrap();
449        assert!(validate_model_string("").is_err());
450        assert!(validate_model_string(&"a".repeat(101)).is_err());
451        assert!(validate_model_string("invalid@model").is_err());
452    }
453}