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