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