1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub enum BackendKind {
7 Gemini,
8 OpenAI,
9 Anthropic,
10 DeepSeek,
11 OpenRouter,
12 Ollama,
13 ZAI,
14 Moonshot,
15 HuggingFace,
16 Minimax,
17 OpenCodeZen,
18 OpenCodeGo,
19}
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
22pub struct Usage {
23 pub prompt_tokens: u32,
24 pub completion_tokens: u32,
25 pub total_tokens: u32,
26 pub cached_prompt_tokens: Option<u32>,
27 pub cache_creation_tokens: Option<u32>,
28 pub cache_read_tokens: Option<u32>,
29}
30
31impl Usage {
32 #[inline]
33 fn has_cache_read_metric(&self) -> bool {
34 self.cache_read_tokens.is_some() || self.cached_prompt_tokens.is_some()
35 }
36
37 #[inline]
38 fn has_any_cache_metrics(&self) -> bool {
39 self.has_cache_read_metric() || self.cache_creation_tokens.is_some()
40 }
41
42 #[inline]
43 pub fn cache_read_tokens_or_fallback(&self) -> u32 {
44 self.cache_read_tokens
45 .or(self.cached_prompt_tokens)
46 .unwrap_or(0)
47 }
48
49 #[inline]
50 pub fn cache_creation_tokens_or_zero(&self) -> u32 {
51 self.cache_creation_tokens.unwrap_or(0)
52 }
53
54 #[inline]
55 pub fn cache_hit_rate(&self) -> Option<f64> {
56 if !self.has_any_cache_metrics() {
57 return None;
58 }
59 let read = self.cache_read_tokens_or_fallback() as f64;
60 let creation = self.cache_creation_tokens_or_zero() as f64;
61 let total = read + creation;
62 if total > 0.0 {
63 Some((read / total) * 100.0)
64 } else {
65 None
66 }
67 }
68
69 #[inline]
70 pub fn is_cache_hit(&self) -> Option<bool> {
71 self.has_any_cache_metrics()
72 .then(|| self.cache_read_tokens_or_fallback() > 0)
73 }
74
75 #[inline]
76 pub fn is_cache_miss(&self) -> Option<bool> {
77 self.has_any_cache_metrics().then(|| {
78 self.cache_creation_tokens_or_zero() > 0 && self.cache_read_tokens_or_fallback() == 0
79 })
80 }
81
82 #[inline]
83 pub fn total_cache_tokens(&self) -> u32 {
84 let read = self.cache_read_tokens_or_fallback();
85 let creation = self.cache_creation_tokens_or_zero();
86 read + creation
87 }
88
89 #[inline]
90 pub fn cache_savings_ratio(&self) -> Option<f64> {
91 if !self.has_cache_read_metric() {
92 return None;
93 }
94 let read = self.cache_read_tokens_or_fallback() as f64;
95 let prompt = self.prompt_tokens as f64;
96 if prompt > 0.0 {
97 Some(read / prompt)
98 } else {
99 None
100 }
101 }
102}
103
104#[cfg(test)]
105mod usage_tests {
106 use super::Usage;
107
108 #[test]
109 fn cache_helpers_fall_back_to_cached_prompt_tokens() {
110 let usage = Usage {
111 prompt_tokens: 1_000,
112 completion_tokens: 200,
113 total_tokens: 1_200,
114 cached_prompt_tokens: Some(600),
115 cache_creation_tokens: Some(150),
116 cache_read_tokens: None,
117 };
118
119 assert_eq!(usage.cache_read_tokens_or_fallback(), 600);
120 assert_eq!(usage.cache_creation_tokens_or_zero(), 150);
121 assert_eq!(usage.total_cache_tokens(), 750);
122 assert_eq!(usage.is_cache_hit(), Some(true));
123 assert_eq!(usage.is_cache_miss(), Some(false));
124 assert_eq!(usage.cache_savings_ratio(), Some(0.6));
125 assert_eq!(usage.cache_hit_rate(), Some(80.0));
126 }
127
128 #[test]
129 fn cache_helpers_preserve_unknown_without_metrics() {
130 let usage = Usage {
131 prompt_tokens: 1_000,
132 completion_tokens: 200,
133 total_tokens: 1_200,
134 cached_prompt_tokens: None,
135 cache_creation_tokens: None,
136 cache_read_tokens: None,
137 };
138
139 assert_eq!(usage.total_cache_tokens(), 0);
140 assert_eq!(usage.is_cache_hit(), None);
141 assert_eq!(usage.is_cache_miss(), None);
142 assert_eq!(usage.cache_savings_ratio(), None);
143 assert_eq!(usage.cache_hit_rate(), None);
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
148pub enum FinishReason {
149 #[default]
150 Stop,
151 Length,
152 ToolCalls,
153 ContentFilter,
154 Pause,
155 Refusal,
156 Error(String),
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct ToolCall {
162 pub id: String,
164
165 #[serde(rename = "type")]
167 pub call_type: String,
168
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub function: Option<FunctionCall>,
172
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub text: Option<String>,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub thought_signature: Option<String>,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct FunctionCall {
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub namespace: Option<String>,
188
189 pub name: String,
191
192 pub arguments: String,
194}
195
196impl ToolCall {
197 pub fn function(id: String, name: String, arguments: String) -> Self {
199 Self::function_with_namespace(id, None, name, arguments)
200 }
201
202 pub fn function_with_namespace(
204 id: String,
205 namespace: Option<String>,
206 name: String,
207 arguments: String,
208 ) -> Self {
209 Self {
210 id,
211 call_type: "function".to_owned(),
212 function: Some(FunctionCall {
213 namespace,
214 name,
215 arguments,
216 }),
217 text: None,
218 thought_signature: None,
219 }
220 }
221
222 pub fn custom(id: String, name: String, text: String) -> Self {
224 Self {
225 id,
226 call_type: "custom".to_owned(),
227 function: Some(FunctionCall {
228 namespace: None,
229 name,
230 arguments: text.clone(),
231 }),
232 text: Some(text),
233 thought_signature: None,
234 }
235 }
236
237 pub fn is_custom(&self) -> bool {
239 self.call_type == "custom"
240 }
241
242 pub fn tool_name(&self) -> Option<&str> {
244 self.function
245 .as_ref()
246 .map(|function| function.name.as_str())
247 }
248
249 pub fn raw_input(&self) -> Option<&str> {
251 self.text.as_deref().or_else(|| {
252 self.function
253 .as_ref()
254 .map(|function| function.arguments.as_str())
255 })
256 }
257
258 pub fn parsed_arguments(&self) -> Result<serde_json::Value, serde_json::Error> {
260 if let Some(ref func) = self.function {
261 parse_tool_arguments(&func.arguments)
262 } else {
263 serde_json::from_str("")
265 }
266 }
267
268 pub fn execution_arguments(&self) -> Result<serde_json::Value, serde_json::Error> {
274 if self.is_custom() {
275 return Ok(serde_json::Value::String(
276 self.raw_input().unwrap_or_default().to_string(),
277 ));
278 }
279
280 self.parsed_arguments()
281 }
282
283 pub fn validate(&self) -> Result<(), String> {
285 if self.id.is_empty() {
286 return Err("Tool call ID cannot be empty".to_owned());
287 }
288
289 match self.call_type.as_str() {
290 "function" => {
291 if let Some(func) = &self.function {
292 if func.name.is_empty() {
293 return Err("Function name cannot be empty".to_owned());
294 }
295 if let Err(e) = self.parsed_arguments() {
297 return Err(format!("Invalid JSON in function arguments: {}", e));
298 }
299 } else {
300 return Err("Function tool call missing function details".to_owned());
301 }
302 }
303 "custom" => {
304 if let Some(func) = &self.function {
306 if func.name.is_empty() {
307 return Err("Custom tool name cannot be empty".to_owned());
308 }
309 } else {
310 return Err("Custom tool call missing function details".to_owned());
311 }
312 }
313 _ => return Err(format!("Unsupported tool call type: {}", self.call_type)),
314 }
315
316 Ok(())
317 }
318}
319
320fn parse_tool_arguments(raw_arguments: &str) -> Result<serde_json::Value, serde_json::Error> {
321 let trimmed = raw_arguments.trim();
322 match serde_json::from_str(trimmed) {
323 Ok(parsed) => Ok(parsed),
324 Err(primary_error) => {
325 if let Some(candidate) = extract_balanced_json(trimmed)
326 && let Ok(parsed) = serde_json::from_str(candidate)
327 {
328 return Ok(parsed);
329 }
330 if let Some(candidate) = repair_tag_polluted_json(trimmed)
331 && let Ok(parsed) = serde_json::from_str(&candidate)
332 {
333 return Ok(parsed);
334 }
335 Err(primary_error)
336 }
337 }
338}
339
340fn extract_balanced_json(input: &str) -> Option<&str> {
341 let start = input.find(['{', '['])?;
342 let opening = input.as_bytes().get(start).copied()?;
343 let closing = match opening {
344 b'{' => b'}',
345 b'[' => b']',
346 _ => return None,
347 };
348
349 let mut depth = 0usize;
350 let mut in_string = false;
351 let mut escaped = false;
352
353 for (offset, ch) in input[start..].char_indices() {
354 if in_string {
355 if escaped {
356 escaped = false;
357 continue;
358 }
359 if ch == '\\' {
360 escaped = true;
361 continue;
362 }
363 if ch == '"' {
364 in_string = false;
365 }
366 continue;
367 }
368
369 match ch {
370 '"' => in_string = true,
371 _ if ch as u32 == opening as u32 => depth += 1,
372 _ if ch as u32 == closing as u32 => {
373 depth = depth.saturating_sub(1);
374 if depth == 0 {
375 let end = start + offset + ch.len_utf8();
376 return input.get(start..end);
377 }
378 }
379 _ => {}
380 }
381 }
382
383 None
384}
385
386fn repair_tag_polluted_json(input: &str) -> Option<String> {
387 let start = input.find(['{', '['])?;
388 let candidate = input.get(start..)?;
389 let boundary = find_provider_markup_boundary(candidate)?;
390 if boundary == 0 {
391 return None;
392 }
393
394 close_incomplete_json_prefix(candidate[..boundary].trim_end())
395}
396
397fn find_provider_markup_boundary(input: &str) -> Option<usize> {
398 const PROVIDER_MARKERS: &[&str] = &[
399 "<</",
400 "</parameter>",
401 "</invoke>",
402 "</minimax:tool_call>",
403 "<minimax:tool_call>",
404 "<parameter name=\"",
405 "<invoke name=\"",
406 "<tool_call>",
407 "</tool_call>",
408 ];
409
410 input.char_indices().find_map(|(offset, _)| {
411 let rest = input.get(offset..)?;
412 PROVIDER_MARKERS
413 .iter()
414 .any(|marker| rest.starts_with(marker))
415 .then_some(offset)
416 })
417}
418
419fn close_incomplete_json_prefix(prefix: &str) -> Option<String> {
420 if prefix.is_empty() {
421 return None;
422 }
423
424 let mut repaired = String::with_capacity(prefix.len() + 8);
425 let mut expected_closers = Vec::new();
426 let mut in_string = false;
427 let mut escaped = false;
428
429 for ch in prefix.chars() {
430 repaired.push(ch);
431
432 if in_string {
433 if escaped {
434 escaped = false;
435 continue;
436 }
437
438 match ch {
439 '\\' => escaped = true,
440 '"' => in_string = false,
441 _ => {}
442 }
443 continue;
444 }
445
446 match ch {
447 '"' => in_string = true,
448 '{' => expected_closers.push('}'),
449 '[' => expected_closers.push(']'),
450 '}' | ']' => {
451 if expected_closers.pop() != Some(ch) {
452 return None;
453 }
454 }
455 _ => {}
456 }
457 }
458
459 if in_string {
460 repaired.push('"');
461 }
462 while let Some(closer) = expected_closers.pop() {
463 repaired.push(closer);
464 }
465
466 Some(repaired)
467}
468
469#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
471pub struct LLMResponse {
472 pub content: Option<String>,
474
475 pub tool_calls: Option<Vec<ToolCall>>,
477
478 pub model: String,
480
481 pub usage: Option<Usage>,
483
484 pub finish_reason: FinishReason,
486
487 pub reasoning: Option<String>,
489
490 pub reasoning_details: Option<Vec<String>>,
492
493 pub tool_references: Vec<String>,
495
496 pub request_id: Option<String>,
498
499 pub organization_id: Option<String>,
501}
502
503impl LLMResponse {
504 pub fn new(model: impl Into<String>, content: impl Into<String>) -> Self {
506 Self {
507 content: Some(content.into()),
508 tool_calls: None,
509 model: model.into(),
510 usage: None,
511 finish_reason: FinishReason::Stop,
512 reasoning: None,
513 reasoning_details: None,
514 tool_references: Vec::new(),
515 request_id: None,
516 organization_id: None,
517 }
518 }
519
520 pub fn content_text(&self) -> &str {
522 self.content.as_deref().unwrap_or("")
523 }
524
525 pub fn content_string(&self) -> String {
527 self.content.clone().unwrap_or_default()
528 }
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
532pub struct LLMErrorMetadata {
533 pub provider: Option<String>,
534 pub status: Option<u16>,
535 pub code: Option<String>,
536 pub request_id: Option<String>,
537 pub organization_id: Option<String>,
538 pub retry_after: Option<String>,
539 pub message: Option<String>,
540}
541
542impl LLMErrorMetadata {
543 #[must_use]
546 pub fn new(
547 provider: impl Into<String>,
548 status: Option<u16>,
549 code: Option<String>,
550 request_id: Option<String>,
551 organization_id: Option<String>,
552 retry_after: Option<String>,
553 message: Option<String>,
554 ) -> Box<Self> {
555 Box::new(Self {
556 provider: Some(provider.into()),
557 status,
558 code,
559 request_id,
560 organization_id,
561 retry_after,
562 message,
563 })
564 }
565}
566
567#[derive(Debug, thiserror::Error, Serialize, Deserialize, Clone)]
569#[serde(tag = "type", rename_all = "snake_case")]
570pub enum LLMError {
571 #[error("Authentication failed: {message}")]
572 Authentication {
573 message: String,
574 metadata: Option<Box<LLMErrorMetadata>>,
575 },
576 #[error("Rate limit exceeded")]
577 RateLimit {
578 metadata: Option<Box<LLMErrorMetadata>>,
579 },
580 #[error("Invalid request: {message}")]
581 InvalidRequest {
582 message: String,
583 metadata: Option<Box<LLMErrorMetadata>>,
584 },
585 #[error("Network error: {message}")]
586 Network {
587 message: String,
588 metadata: Option<Box<LLMErrorMetadata>>,
589 },
590 #[error("Provider error: {message}")]
591 Provider {
592 message: String,
593 metadata: Option<Box<LLMErrorMetadata>>,
594 },
595}
596
597#[cfg(test)]
598mod tests {
599 use super::ToolCall;
600 use serde_json::json;
601
602 #[test]
603 fn parsed_arguments_accepts_trailing_characters() {
604 let call = ToolCall::function(
605 "call_read".to_string(),
606 "read_file".to_string(),
607 r#"{"path":"src/main.rs"} trailing text"#.to_string(),
608 );
609
610 let parsed = call
611 .parsed_arguments()
612 .expect("arguments with trailing text should recover");
613 assert_eq!(parsed, json!({"path":"src/main.rs"}));
614 }
615
616 #[test]
617 fn parsed_arguments_accepts_code_fenced_json() {
618 let call = ToolCall::function(
619 "call_read".to_string(),
620 "read_file".to_string(),
621 "```json\n{\"path\":\"src/lib.rs\",\"limit\":25}\n```".to_string(),
622 );
623
624 let parsed = call
625 .parsed_arguments()
626 .expect("code-fenced arguments should recover");
627 assert_eq!(parsed, json!({"path":"src/lib.rs","limit":25}));
628 }
629
630 #[test]
631 fn parsed_arguments_rejects_incomplete_json() {
632 let call = ToolCall::function(
633 "call_read".to_string(),
634 "read_file".to_string(),
635 r#"{"path":"src/main.rs""#.to_string(),
636 );
637
638 assert!(call.parsed_arguments().is_err());
639 }
640
641 #[test]
642 fn parsed_arguments_recovers_truncated_minimax_markup() {
643 let call = ToolCall::function(
644 "call_search".to_string(),
645 "unified_search".to_string(),
646 "{\"action\": \"grep\", \"pattern\": \"persistent_memory\", \"path\": \"vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
647 );
648
649 let parsed = call
650 .parsed_arguments()
651 .expect("minimax markup spillover should recover");
652 assert_eq!(
653 parsed,
654 json!({
655 "action": "grep",
656 "pattern": "persistent_memory",
657 "path": "vtcode-core/src"
658 })
659 );
660 }
661
662 #[test]
663 fn function_call_serializes_optional_namespace() {
664 let call = ToolCall::function_with_namespace(
665 "call_read".to_string(),
666 Some("workspace".to_string()),
667 "read_file".to_string(),
668 r#"{"path":"src/main.rs"}"#.to_string(),
669 );
670
671 let json = serde_json::to_value(&call).expect("tool call should serialize");
672 assert_eq!(json["function"]["namespace"], "workspace");
673 assert_eq!(json["function"]["name"], "read_file");
674 }
675
676 #[test]
677 fn custom_tool_call_exposes_raw_execution_arguments() {
678 let patch = "*** Begin Patch\n*** End Patch\n".to_string();
679 let call = ToolCall::custom(
680 "call_patch".to_string(),
681 "apply_patch".to_string(),
682 patch.clone(),
683 );
684
685 assert!(call.is_custom());
686 assert_eq!(call.tool_name(), Some("apply_patch"));
687 assert_eq!(call.raw_input(), Some(patch.as_str()));
688 assert_eq!(
689 call.execution_arguments().expect("custom arguments"),
690 json!(patch)
691 );
692 assert!(
693 call.parsed_arguments().is_err(),
694 "custom tool payload should stay freeform rather than JSON"
695 );
696 }
697}