1use serde::{Deserialize, Serialize, ser::SerializeStruct};
4use std::fmt;
5
6use crate::sanitizer::sanitize_provider_diagnostic;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum BackendKind {
10 Gemini,
11 OpenAI,
12 Anthropic,
13 DeepSeek,
14 Meta,
15 Mistral,
16 OpenRouter,
17 Ollama,
18 LlamaCpp,
19 ZAI,
20 Moonshot,
21 HuggingFace,
22 Minimax,
23 MiMo,
24 OpenCodeZen,
25 OpenCodeGo,
26 Qwen,
27 StepFun,
28 Evolink,
29 Poolside,
30 Xai,
31 Nvidia,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
35pub struct Usage {
36 pub prompt_tokens: u32,
37 pub completion_tokens: u32,
38 pub total_tokens: u32,
39 pub cached_prompt_tokens: Option<u32>,
40 pub cache_creation_tokens: Option<u32>,
41 pub cache_read_tokens: Option<u32>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub iterations: Option<Vec<serde_json::Value>>,
46}
47
48impl Usage {
49 #[inline]
50 fn has_cache_read_metric(&self) -> bool {
51 self.cache_read_tokens.is_some() || self.cached_prompt_tokens.is_some()
52 }
53
54 #[inline]
55 fn has_any_cache_metrics(&self) -> bool {
56 self.has_cache_read_metric() || self.cache_creation_tokens.is_some()
57 }
58
59 #[inline]
60 pub fn cache_read_tokens_or_fallback(&self) -> u32 {
61 self.cache_read_tokens.or(self.cached_prompt_tokens).unwrap_or(0)
62 }
63
64 #[inline]
65 pub fn cache_creation_tokens_or_zero(&self) -> u32 {
66 self.cache_creation_tokens.unwrap_or(0)
67 }
68
69 #[inline]
70 pub fn cache_hit_rate(&self) -> Option<f64> {
71 if !self.has_any_cache_metrics() {
72 return None;
73 }
74 let read = self.cache_read_tokens_or_fallback() as f64;
75 let creation = self.cache_creation_tokens_or_zero() as f64;
76 let total = read + creation;
77 if total > 0.0 {
78 Some((read / total) * 100.0)
79 } else {
80 None
81 }
82 }
83
84 #[inline]
85 fn is_cache_hit(&self) -> Option<bool> {
86 self.has_any_cache_metrics().then(|| self.cache_read_tokens_or_fallback() > 0)
87 }
88
89 #[inline]
90 fn is_cache_miss(&self) -> Option<bool> {
91 self.has_any_cache_metrics()
92 .then(|| self.cache_creation_tokens_or_zero() > 0 && self.cache_read_tokens_or_fallback() == 0)
93 }
94
95 #[inline]
96 fn total_cache_tokens(&self) -> u32 {
97 let read = self.cache_read_tokens_or_fallback();
98 let creation = self.cache_creation_tokens_or_zero();
99 read + creation
100 }
101
102 #[inline]
103 fn cache_savings_ratio(&self) -> Option<f64> {
104 if !self.has_cache_read_metric() {
105 return None;
106 }
107 let read = self.cache_read_tokens_or_fallback() as f64;
108 let prompt = self.prompt_tokens as f64;
109 if prompt > 0.0 { Some(read / prompt) } else { None }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct BalanceInfo {
116 pub display: String,
118 pub is_available: bool,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct DeepSeekBalanceResponse {
125 is_available: bool,
126 balance_infos: Vec<DeepSeekCurrencyBalance>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct DeepSeekCurrencyBalance {
131 currency: String,
132 total_balance: String,
133 #[serde(default)]
134 granted_balance: String,
135 #[serde(default)]
136 topped_up_balance: String,
137}
138
139impl From<DeepSeekBalanceResponse> for BalanceInfo {
140 fn from(resp: DeepSeekBalanceResponse) -> Self {
141 let display = resp
142 .balance_infos
143 .first()
144 .map(|b| {
145 let symbol = match b.currency.as_str() {
146 "CNY" => "¥",
147 "USD" => "$",
148 _ => &b.currency,
149 };
150 format!("{}{}", b.total_balance, symbol)
151 })
152 .unwrap_or_else(|| "N/A".to_string());
153 BalanceInfo { display, is_available: resp.is_available }
154 }
155}
156
157#[cfg(test)]
158mod usage_tests {
159 use super::Usage;
160
161 #[test]
162 fn cache_helpers_fall_back_to_cached_prompt_tokens() {
163 let usage = Usage {
164 prompt_tokens: 1_000,
165 completion_tokens: 200,
166 total_tokens: 1_200,
167 cached_prompt_tokens: Some(600),
168 cache_creation_tokens: Some(150),
169 cache_read_tokens: None,
170 iterations: 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 iterations: None,
192 };
193
194 assert_eq!(usage.total_cache_tokens(), 0);
195 assert_eq!(usage.is_cache_hit(), None);
196 assert_eq!(usage.is_cache_miss(), None);
197 assert_eq!(usage.cache_savings_ratio(), None);
198 assert_eq!(usage.cache_hit_rate(), None);
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
203pub enum FinishReason {
204 #[default]
205 Stop,
206 Length,
207 ToolCalls,
208 ContentFilter,
209 Pause,
210 Refusal,
211 Error(String),
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct ToolCall {
217 pub id: String,
219
220 #[serde(rename = "type")]
222 pub call_type: String,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub function: Option<FunctionCall>,
227
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub text: Option<String>,
231
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub thought_signature: Option<String>,
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct FunctionCall {
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub namespace: Option<String>,
243
244 pub name: String,
246
247 pub arguments: String,
249}
250
251impl ToolCall {
252 pub fn function(id: String, name: String, arguments: String) -> Self {
254 Self::function_with_namespace(id, None, name, arguments)
255 }
256
257 pub fn function_with_namespace(id: String, namespace: Option<String>, name: String, arguments: String) -> Self {
259 Self {
260 id,
261 call_type: "function".to_owned(),
262 function: Some(FunctionCall { namespace, name, arguments }),
263 text: None,
264 thought_signature: None,
265 }
266 }
267
268 pub fn custom(id: String, name: String, text: String) -> Self {
270 Self {
271 id,
272 call_type: "custom".to_owned(),
273 function: Some(FunctionCall { namespace: None, name, arguments: text.clone() }),
274 text: Some(text),
275 thought_signature: None,
276 }
277 }
278
279 pub fn is_custom(&self) -> bool {
281 self.call_type == "custom"
282 }
283
284 pub fn tool_name(&self) -> Option<&str> {
286 self.function.as_ref().map(|function| function.name.as_str())
287 }
288
289 pub fn raw_input(&self) -> Option<&str> {
291 self.text
292 .as_deref()
293 .or_else(|| self.function.as_ref().map(|function| function.arguments.as_str()))
294 }
295
296 pub fn parsed_arguments(&self) -> Result<serde_json::Value, serde_json::Error> {
298 if let Some(ref func) = self.function {
299 parse_tool_arguments(&func.arguments)
300 } else {
301 serde_json::from_str("")
303 }
304 }
305
306 pub fn execution_arguments(&self) -> Result<serde_json::Value, serde_json::Error> {
312 if self.is_custom() {
313 return Ok(serde_json::Value::String(self.raw_input().unwrap_or_default().to_string()));
314 }
315
316 self.parsed_arguments()
317 }
318
319 pub fn validate(&self) -> Result<(), String> {
321 if self.id.is_empty() {
322 return Err("Tool call ID cannot be empty".to_owned());
323 }
324
325 match self.call_type.as_str() {
326 "function" => {
327 if let Some(func) = &self.function {
328 if func.name.is_empty() {
329 return Err("Function name cannot be empty".to_owned());
330 }
331 if let Err(e) = self.parsed_arguments() {
333 return Err(format!("Invalid JSON in function arguments: {e}"));
334 }
335 } else {
336 return Err("Function tool call missing function details".to_owned());
337 }
338 }
339 "custom" => {
340 if let Some(func) = &self.function {
342 if func.name.is_empty() {
343 return Err("Custom tool name cannot be empty".to_owned());
344 }
345 } else {
346 return Err("Custom tool call missing function details".to_owned());
347 }
348 }
349 _ => return Err(format!("Unsupported tool call type: {}", self.call_type)),
350 }
351
352 Ok(())
353 }
354}
355
356fn parse_tool_arguments(raw_arguments: &str) -> Result<serde_json::Value, serde_json::Error> {
357 let trimmed = raw_arguments.trim();
358 match serde_json::from_str(trimmed) {
359 Ok(parsed) => Ok(parsed),
360 Err(primary_error) => {
361 if let Some(candidate) = extract_balanced_json(trimmed)
362 && let Ok(parsed) = serde_json::from_str(candidate)
363 {
364 return Ok(parsed);
365 }
366 if let Some(candidate) = repair_tag_polluted_json(trimmed)
367 && let Ok(parsed) = serde_json::from_str(&candidate)
368 {
369 return Ok(parsed);
370 }
371 if let Some(repaired) = close_incomplete_json_prefix(trimmed)
372 && let Ok(parsed) = serde_json::from_str(&repaired)
373 {
374 return Ok(parsed);
375 }
376 Err(primary_error)
377 }
378 }
379}
380
381fn extract_balanced_json(input: &str) -> Option<&str> {
382 let start = input.find(['{', '['])?;
383 let opening = input.as_bytes().get(start).copied()?;
384 let closing = match opening {
385 b'{' => b'}',
386 b'[' => b']',
387 _ => return None,
388 };
389
390 let mut depth = 0usize;
391 let mut in_string = false;
392 let mut escaped = false;
393
394 for (offset, ch) in input.get(start..)?.char_indices() {
395 if in_string {
396 if escaped {
397 escaped = false;
398 continue;
399 }
400 if ch == '\\' {
401 escaped = true;
402 continue;
403 }
404 if ch == '"' {
405 in_string = false;
406 }
407 continue;
408 }
409
410 match ch {
411 '"' => in_string = true,
412 _ if ch as u32 == opening as u32 => depth += 1,
413 _ if ch as u32 == closing as u32 => {
414 depth = depth.saturating_sub(1);
415 if depth == 0 {
416 let end = start + offset + ch.len_utf8();
417 return input.get(start..end);
418 }
419 }
420 _ => {}
421 }
422 }
423
424 None
425}
426
427fn repair_tag_polluted_json(input: &str) -> Option<String> {
428 let start = input.find(['{', '['])?;
429 let candidate = input.get(start..)?;
430 let boundary = find_provider_markup_boundary(candidate)?;
431 if boundary == 0 {
432 return None;
433 }
434
435 close_incomplete_json_prefix(candidate.get(..boundary)?.trim_end())
436}
437
438fn find_provider_markup_boundary(input: &str) -> Option<usize> {
439 const PROVIDER_MARKERS: &[&str] = &[
440 "<</",
441 "</parameter>",
442 "</invoke>",
443 "</minimax:tool_call>",
444 "<minimax:tool_call>",
445 "<parameter name=\"",
446 "<invoke name=\"",
447 "<tool_call>",
448 "</tool_call>",
449 ];
450
451 input.char_indices().find_map(|(offset, _)| {
452 let rest = input.get(offset..)?;
453 PROVIDER_MARKERS.iter().any(|marker| rest.starts_with(marker)).then_some(offset)
454 })
455}
456
457fn close_incomplete_json_prefix(prefix: &str) -> Option<String> {
458 if prefix.is_empty() {
459 return None;
460 }
461
462 let mut repaired = String::with_capacity(prefix.len() + 8);
463 let mut expected_closers = Vec::new();
464 let mut in_string = false;
465 let mut escaped = false;
466
467 for ch in prefix.chars() {
468 repaired.push(ch);
469
470 if in_string {
471 if escaped {
472 escaped = false;
473 continue;
474 }
475
476 match ch {
477 '\\' => escaped = true,
478 '"' => in_string = false,
479 _ => {}
480 }
481 continue;
482 }
483
484 match ch {
485 '"' => in_string = true,
486 '{' => expected_closers.push('}'),
487 '[' => expected_closers.push(']'),
488 '}' | ']' if expected_closers.pop() != Some(ch) => return None,
489 '}' | ']' => {}
490 _ => {}
491 }
492 }
493
494 if in_string {
495 repaired.push('"');
496 }
497 for closer in expected_closers.drain(..) {
498 repaired.push(closer);
499 }
500
501 Some(repaired)
502}
503
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
506pub struct LLMResponse {
507 pub content: Option<String>,
509
510 pub tool_calls: Option<Vec<ToolCall>>,
512
513 pub model: String,
515
516 pub usage: Option<Usage>,
518
519 pub finish_reason: FinishReason,
521
522 pub reasoning: Option<String>,
524
525 pub reasoning_details: Option<Vec<String>>,
527
528 pub tool_references: Vec<String>,
530
531 pub request_id: Option<String>,
533
534 pub organization_id: Option<String>,
536
537 pub compaction: Option<String>,
542}
543
544impl LLMResponse {
545 pub fn new(model: impl Into<String>, content: impl Into<String>) -> Self {
547 Self {
548 content: Some(content.into()),
549 tool_calls: None,
550 model: model.into(),
551 usage: None,
552 finish_reason: FinishReason::Stop,
553 reasoning: None,
554 reasoning_details: None,
555 tool_references: Vec::new(),
556 request_id: None,
557 organization_id: None,
558 compaction: None,
559 }
560 }
561
562 pub fn content_text(&self) -> &str {
564 self.content.as_deref().unwrap_or("")
565 }
566
567 pub fn content_string(&self) -> String {
569 self.content.clone().unwrap_or_default()
570 }
571}
572
573#[derive(Clone, Deserialize, PartialEq, Eq)]
574pub struct LLMErrorMetadata {
575 provider: Option<String>,
576 pub status: Option<u16>,
577 pub code: Option<String>,
578 request_id: Option<String>,
579 organization_id: Option<String>,
580 pub retry_after: Option<String>,
581 pub message: Option<String>,
582}
583
584impl fmt::Debug for LLMErrorMetadata {
585 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
586 formatter
587 .debug_struct("LLMErrorMetadata")
588 .field("provider", &self.provider)
589 .field("status", &self.status)
590 .field("code", &self.code)
591 .field("request_id", &self.request_id)
592 .field("organization_id", &self.organization_id)
593 .field("retry_after", &self.retry_after)
594 .field(
595 "message",
596 &self
597 .message
598 .as_deref()
599 .map(|message| sanitize_provider_diagnostic(message.as_bytes())),
600 )
601 .finish()
602 }
603}
604
605impl Serialize for LLMErrorMetadata {
606 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
607 where
608 S: serde::Serializer,
609 {
610 let mut state = serializer.serialize_struct("LLMErrorMetadata", 7)?;
611 state.serialize_field("provider", &self.provider)?;
612 state.serialize_field("status", &self.status)?;
613 state.serialize_field("code", &self.code)?;
614 state.serialize_field("request_id", &self.request_id)?;
615 state.serialize_field("organization_id", &self.organization_id)?;
616 state.serialize_field("retry_after", &self.retry_after)?;
617 let message = self
618 .message
619 .as_deref()
620 .map(|message| sanitize_provider_diagnostic(message.as_bytes()));
621 state.serialize_field("message", &message)?;
622 state.end()
623 }
624}
625
626impl LLMErrorMetadata {
627 #[must_use]
630 pub fn new(
631 provider: impl Into<String>,
632 status: Option<u16>,
633 code: Option<String>,
634 request_id: Option<String>,
635 organization_id: Option<String>,
636 retry_after: Option<String>,
637 message: Option<String>,
638 ) -> Box<Self> {
639 Box::new(Self {
640 provider: Some(provider.into()),
641 status,
642 code,
643 request_id,
644 organization_id,
645 retry_after,
646 message: message.map(|message| sanitize_provider_diagnostic(message.as_bytes())),
647 })
648 }
649}
650
651#[derive(Deserialize, Clone)]
653#[serde(tag = "type", rename_all = "snake_case")]
654pub enum LLMError {
655 Authentication {
656 message: String,
657 metadata: Option<Box<LLMErrorMetadata>>,
658 },
659 RateLimit {
660 metadata: Option<Box<LLMErrorMetadata>>,
661 },
662 InvalidRequest {
663 message: String,
664 metadata: Option<Box<LLMErrorMetadata>>,
665 },
666 Network {
667 message: String,
668 metadata: Option<Box<LLMErrorMetadata>>,
669 },
670 Provider {
671 message: String,
672 metadata: Option<Box<LLMErrorMetadata>>,
673 },
674}
675
676impl fmt::Debug for LLMError {
677 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
678 match self {
679 Self::Authentication { message, metadata } => formatter
680 .debug_struct("Authentication")
681 .field("message", &sanitize_provider_diagnostic(message.as_bytes()))
682 .field("metadata", metadata)
683 .finish(),
684 Self::RateLimit { metadata } => formatter.debug_struct("RateLimit").field("metadata", metadata).finish(),
685 Self::InvalidRequest { message, metadata } => formatter
686 .debug_struct("InvalidRequest")
687 .field("message", &sanitize_provider_diagnostic(message.as_bytes()))
688 .field("metadata", metadata)
689 .finish(),
690 Self::Network { message, metadata } => formatter
691 .debug_struct("Network")
692 .field("message", &sanitize_provider_diagnostic(message.as_bytes()))
693 .field("metadata", metadata)
694 .finish(),
695 Self::Provider { message, metadata } => formatter
696 .debug_struct("Provider")
697 .field("message", &sanitize_provider_diagnostic(message.as_bytes()))
698 .field("metadata", metadata)
699 .finish(),
700 }
701 }
702}
703
704impl fmt::Display for LLMError {
705 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
706 match self {
707 Self::Authentication { message, .. } => {
708 write!(formatter, "Authentication failed: {}", sanitize_provider_diagnostic(message.as_bytes()))
709 }
710 Self::RateLimit { .. } => formatter.write_str("Rate limit exceeded"),
711 Self::InvalidRequest { message, .. } => {
712 write!(formatter, "Invalid request: {}", sanitize_provider_diagnostic(message.as_bytes()))
713 }
714 Self::Network { message, .. } => {
715 write!(formatter, "Network error: {}", sanitize_provider_diagnostic(message.as_bytes()))
716 }
717 Self::Provider { message, .. } => {
718 write!(formatter, "Provider error: {}", sanitize_provider_diagnostic(message.as_bytes()))
719 }
720 }
721 }
722}
723
724impl std::error::Error for LLMError {}
725
726impl Serialize for LLMError {
727 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
728 where
729 S: serde::Serializer,
730 {
731 match self {
732 Self::Authentication { message, metadata } => {
733 let mut state = serializer.serialize_struct("LLMError", 3)?;
734 state.serialize_field("type", "authentication")?;
735 state.serialize_field("message", &sanitize_provider_diagnostic(message.as_bytes()))?;
736 state.serialize_field("metadata", metadata)?;
737 state.end()
738 }
739 Self::RateLimit { metadata } => {
740 let mut state = serializer.serialize_struct("LLMError", 2)?;
741 state.serialize_field("type", "rate_limit")?;
742 state.serialize_field("metadata", metadata)?;
743 state.end()
744 }
745 Self::InvalidRequest { message, metadata } => {
746 let mut state = serializer.serialize_struct("LLMError", 3)?;
747 state.serialize_field("type", "invalid_request")?;
748 state.serialize_field("message", &sanitize_provider_diagnostic(message.as_bytes()))?;
749 state.serialize_field("metadata", metadata)?;
750 state.end()
751 }
752 Self::Network { message, metadata } => {
753 let mut state = serializer.serialize_struct("LLMError", 3)?;
754 state.serialize_field("type", "network")?;
755 state.serialize_field("message", &sanitize_provider_diagnostic(message.as_bytes()))?;
756 state.serialize_field("metadata", metadata)?;
757 state.end()
758 }
759 Self::Provider { message, metadata } => {
760 let mut state = serializer.serialize_struct("LLMError", 3)?;
761 state.serialize_field("type", "provider")?;
762 state.serialize_field("message", &sanitize_provider_diagnostic(message.as_bytes()))?;
763 state.serialize_field("metadata", metadata)?;
764 state.end()
765 }
766 }
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use super::{LLMError, LLMErrorMetadata, ToolCall};
773 use serde_json::json;
774
775 #[test]
776 fn parsed_arguments_accepts_trailing_characters() {
777 let call = ToolCall::function(
778 "call_read".to_string(),
779 "exec_command".to_string(),
780 r#"{"path":"src/main.rs"} trailing text"#.to_string(),
781 );
782
783 let parsed = call.parsed_arguments().expect("arguments with trailing text should recover");
784 assert_eq!(parsed, json!({"path":"src/main.rs"}));
785 }
786
787 #[test]
788 fn parsed_arguments_accepts_code_fenced_json() {
789 let call = ToolCall::function(
790 "call_read".to_string(),
791 "exec_command".to_string(),
792 "```json\n{\"path\":\"src/lib.rs\",\"limit\":25}\n```".to_string(),
793 );
794
795 let parsed = call.parsed_arguments().expect("code-fenced arguments should recover");
796 assert_eq!(parsed, json!({"path":"src/lib.rs","limit":25}));
797 }
798
799 #[test]
800 fn parsed_arguments_recovers_truncated_json_missing_closing_brace() {
801 let call = ToolCall::function(
802 "call_search".to_string(),
803 "code_search".to_string(),
804 r#"{"query":"context","path":".","file_types":["rust"],"result_types":["definition"],"max_results":20"#
805 .to_string(),
806 );
807
808 let parsed = call
809 .parsed_arguments()
810 .expect("truncated JSON missing closing brace should recover");
811 assert_eq!(
812 parsed,
813 json!({
814 "query": "context",
815 "path": ".",
816 "file_types": ["rust"],
817 "result_types": ["definition"],
818 "max_results": 20
819 })
820 );
821 }
822
823 #[test]
824 fn parsed_arguments_rejects_incomplete_json() {
825 let call = ToolCall::function(
826 "call_read".to_string(),
827 "exec_command".to_string(),
828 r#"{"path":"src/main.rs","limit""#.to_string(),
829 );
830
831 assert!(call.parsed_arguments().is_err());
832 }
833
834 #[test]
835 fn llm_error_debug_and_json_redact_provider_secrets() {
836 let secret = "sk-test1234567890abcdefghij";
837 let error = LLMError::Provider {
838 message: format!("response body api_key={secret} bearer Bearer abcdefghijklmnop"),
839 metadata: Some(LLMErrorMetadata::new(
840 "OpenAI",
841 Some(401),
842 Some("invalid_api_key".to_owned()),
843 Some("req-123".to_owned()),
844 None,
845 None,
846 Some("AWS_SECRET_ACCESS_KEY=cloud-secret-value".to_owned()),
847 )),
848 };
849
850 let debug = format!("{error:?}");
851 let json = serde_json::to_string(&error).expect("LLM errors should serialize");
852
853 assert!(!debug.contains(secret));
854 assert!(!debug.contains("cloud-secret-value"));
855 assert!(!json.contains(secret));
856 assert!(!json.contains("cloud-secret-value"));
857 assert!(json.contains("req-123"));
858 assert!(json.contains("401"));
859 }
860
861 #[test]
862 fn parsed_arguments_recovers_truncated_minimax_markup() {
863 let call = ToolCall::function(
864 "call_search".to_string(),
865 "code_search".to_string(),
866 "{\"query\":\"persistent_memory\",\"file_types\":[\"rust\"],\"result_types\":[\"text\"],\"max_results\":20,\"path\":\"crates/codegen/vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
867 );
868
869 let parsed = call.parsed_arguments().expect("minimax markup spillover should recover");
870 assert_eq!(
871 parsed,
872 json!({
873 "query": "persistent_memory",
874 "path": "crates/codegen/vtcode-core/src",
875 "file_types": ["rust"],
876 "result_types": ["text"],
877 "max_results": 20
878 })
879 );
880 }
881
882 #[test]
883 fn function_call_serializes_optional_namespace() {
884 let call = ToolCall::function_with_namespace(
885 "call_read".to_string(),
886 Some("workspace".to_string()),
887 "exec_command".to_string(),
888 r#"{"path":"src/main.rs"}"#.to_string(),
889 );
890
891 let json = serde_json::to_value(&call).expect("tool call should serialize");
892 assert_eq!(json["function"]["namespace"], "workspace");
893 assert_eq!(json["function"]["name"], "exec_command");
894 }
895
896 #[test]
897 fn custom_tool_call_exposes_raw_execution_arguments() {
898 let patch = "*** Begin Patch\n*** End Patch\n".to_string();
899 let call = ToolCall::custom("call_patch".to_string(), "apply_patch".to_string(), patch.clone());
900
901 assert!(call.is_custom());
902 assert_eq!(call.tool_name(), Some("apply_patch"));
903 assert_eq!(call.raw_input(), Some(patch.as_str()));
904 assert_eq!(call.execution_arguments().expect("custom arguments"), json!(patch));
905 assert!(call.parsed_arguments().is_err(), "custom tool payload should stay freeform rather than JSON");
906 }
907}