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