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