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