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