Skip to main content

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