1use anyhow::{Context, Result};
7use serde_json::Value;
8
9use crate::llm::provider::{LLMRequest, LLMResponse, LLMStreamEvent, ToolCall};
10
11pub 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
16pub 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 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 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(), name.to_string(),
65 arguments.to_string(),
66 ))
67 });
68
69 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 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
112pub 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
130pub 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 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
263pub fn format_llm_error(provider_name: &str, error_message: &str) -> String {
265 format!("[{}] {}", provider_name, error_message.trim())
266}
267
268pub 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 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 let content = "<think>Step 1: Plan</think> text <analysis>Step 2: Analyze</think> end";
410 let (reasoning, main) = extract_reasoning_content(content);
411
412 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); assert_eq!(estimate_token_count(""), 0); assert_eq!(estimate_token_count("a"), 1); }
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}