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