Skip to main content

vtcode_commons/
llm.rs

1//! Core LLM types shared across the project
2
3use 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    /// Per-iteration token usage for Anthropic server-side fallback and compaction.
42    /// Each entry represents one sampling pass (message, fallback_message, or compaction).
43    #[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/// Provider-agnostic balance information for account status display.
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
114pub struct BalanceInfo {
115    /// Human-readable balance string (e.g. "100.00¥", "$50.00").
116    pub display: String,
117    /// Whether the account has sufficient balance for API calls.
118    pub is_available: bool,
119}
120
121/// DeepSeek-specific balance info from GET /user/balance
122#[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/// Universal tool call that matches OpenAI/Anthropic/Gemini specifications
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct ToolCall {
216    /// Unique identifier for this tool call (e.g., "call_123")
217    pub id: String,
218
219    /// The type of tool call: "function", "custom" (GPT-5 freeform), or other
220    #[serde(rename = "type")]
221    pub call_type: String,
222
223    /// Function call details (for function-type tools)
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub function: Option<FunctionCall>,
226
227    /// Raw text payload (for custom freeform tools in GPT-5)
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub text: Option<String>,
230
231    /// Gemini-specific thought signature for maintaining reasoning context
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub thought_signature: Option<String>,
234}
235
236/// Function call within a tool call
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct FunctionCall {
239    /// Optional namespace for grouped or deferred tools.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub namespace: Option<String>,
242
243    /// The name of the function to call
244    pub name: String,
245
246    /// The arguments to pass to the function, as a JSON string
247    pub arguments: String,
248}
249
250impl ToolCall {
251    /// Create a new function tool call
252    pub fn function(id: String, name: String, arguments: String) -> Self {
253        Self::function_with_namespace(id, None, name, arguments)
254    }
255
256    /// Create a new function tool call with an optional namespace.
257    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    /// Create a new custom tool call with raw text payload (GPT-5 freeform)
268    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    /// Returns true when this tool call uses GPT-5 custom/freeform semantics.
279    pub fn is_custom(&self) -> bool {
280        self.call_type == "custom"
281    }
282
283    /// Returns the tool name when the call includes function details.
284    pub fn tool_name(&self) -> Option<&str> {
285        self.function.as_ref().map(|function| function.name.as_str())
286    }
287
288    /// Returns the raw payload text exactly as emitted by the model.
289    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    /// Parse the arguments as JSON Value (for function-type tools)
296    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            // Return an error by trying to parse invalid JSON
301            serde_json::from_str("")
302        }
303    }
304
305    /// Returns the execution payload for this tool call.
306    ///
307    /// Function tools keep their JSON semantics. Custom tools execute with their
308    /// raw text payload wrapped as a JSON string value so freeform inputs can
309    /// flow through the existing tool pipeline.
310    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    /// Validate that this tool call is properly formed
319    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                    // Validate that arguments is valid JSON for function tools
331                    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                // For custom tools, we allow raw text payload without JSON validation
340                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/// Universal LLM response structure
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
505pub struct LLMResponse {
506    /// The response content text
507    pub content: Option<String>,
508
509    /// Tool calls made by the model
510    pub tool_calls: Option<Vec<ToolCall>>,
511
512    /// The model that generated this response
513    pub model: String,
514
515    /// Token usage statistics
516    pub usage: Option<Usage>,
517
518    /// Why the response finished
519    pub finish_reason: FinishReason,
520
521    /// Reasoning content (for models that support it)
522    pub reasoning: Option<String>,
523
524    /// Detailed reasoning traces (for models that support it)
525    pub reasoning_details: Option<Vec<String>>,
526
527    /// Tool references for context
528    pub tool_references: Vec<String>,
529
530    /// Request ID from the provider
531    pub request_id: Option<String>,
532
533    /// Organization ID from the provider
534    pub organization_id: Option<String>,
535
536    /// Compaction summary content from Anthropic's server-side compaction.
537    /// Populated when `stop_reason` is `Pause` (from `"compaction"`).
538    /// The caller should pass this back in subsequent requests so the API
539    /// can drop prior messages before the compaction block.
540    pub compaction: Option<String>,
541}
542
543impl LLMResponse {
544    /// Create a new LLM response with mandatory fields
545    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    /// Get content or empty string
562    pub fn content_text(&self) -> &str {
563        self.content.as_deref().unwrap_or("")
564    }
565
566    /// Get content as String (clone)
567    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    /// Boxed constructor because metadata is always stored inside `Option<Box<LLMErrorMetadata>>`
627    /// in the LLMError enum variants.
628    #[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/// LLM error types with optional provider metadata
651#[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}