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