1use anyhow::{Context, Result};
7use serde_json::Value;
8use vtcode_commons::validation::NonEmptySlice;
9
10use crate::provider::{LLMRequest, LLMResponse, LLMStreamEvent, ToolCall};
11
12pub(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
17pub(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 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 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(), name.to_string(),
55 arguments.to_string(),
56 ))
57 });
58
59 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 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
98pub 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
109pub(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 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
236fn format_llm_error(provider_name: &str, error_message: &str) -> String {
238 format!("[{}] {}", provider_name, error_message.trim())
239}
240
241fn 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 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 let content = "<think>Step 1: Plan</think> text <analysis>Step 2: Analyze</think> end";
436 let (reasoning, main) = extract_reasoning_content(content);
437
438 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 assert_eq!(estimate_token_count("Hello world"), 2);
450 assert_eq!(estimate_token_count(""), 0); assert_eq!(estimate_token_count("a"), 1); }
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}