Skip to main content

vtcode_commons/
llm.rs

1//! Core LLM types shared across the project
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub enum BackendKind {
7    Gemini,
8    OpenAI,
9    Anthropic,
10    DeepSeek,
11    OpenRouter,
12    Ollama,
13    XAI,
14    ZAI,
15    Moonshot,
16    HuggingFace,
17    Minimax,
18}
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
21pub struct Usage {
22    pub prompt_tokens: u32,
23    pub completion_tokens: u32,
24    pub total_tokens: u32,
25    pub cached_prompt_tokens: Option<u32>,
26    pub cache_creation_tokens: Option<u32>,
27    pub cache_read_tokens: Option<u32>,
28}
29
30impl Usage {
31    #[inline]
32    pub fn cache_hit_rate(&self) -> Option<f64> {
33        let read = self.cache_read_tokens? as f64;
34        let creation = self.cache_creation_tokens? as f64;
35        let total = read + creation;
36        if total > 0.0 {
37            Some((read / total) * 100.0)
38        } else {
39            None
40        }
41    }
42
43    #[inline]
44    pub fn is_cache_hit(&self) -> Option<bool> {
45        Some(self.cache_read_tokens? > 0)
46    }
47
48    #[inline]
49    pub fn is_cache_miss(&self) -> Option<bool> {
50        Some(self.cache_creation_tokens? > 0 && self.cache_read_tokens? == 0)
51    }
52
53    #[inline]
54    pub fn total_cache_tokens(&self) -> u32 {
55        let read = self.cache_read_tokens.unwrap_or(0);
56        let creation = self.cache_creation_tokens.unwrap_or(0);
57        read + creation
58    }
59
60    #[inline]
61    pub fn cache_savings_ratio(&self) -> Option<f64> {
62        let read = self.cache_read_tokens? as f64;
63        let prompt = self.prompt_tokens as f64;
64        if prompt > 0.0 {
65            Some(read / prompt)
66        } else {
67            None
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub enum FinishReason {
74    Stop,
75    Length,
76    ToolCalls,
77    ContentFilter,
78    Pause,
79    Refusal,
80    Error(String),
81}
82
83/// Universal tool call that matches OpenAI/Anthropic/Gemini specifications
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct ToolCall {
86    /// Unique identifier for this tool call (e.g., "call_123")
87    pub id: String,
88
89    /// The type of tool call: "function", "custom" (GPT-5 freeform), or other
90    #[serde(rename = "type")]
91    pub call_type: String,
92
93    /// Function call details (for function-type tools)
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub function: Option<FunctionCall>,
96
97    /// Raw text payload (for custom freeform tools in GPT-5)
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub text: Option<String>,
100
101    /// Gemini-specific thought signature for maintaining reasoning context
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub thought_signature: Option<String>,
104}
105
106/// Function call within a tool call
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108pub struct FunctionCall {
109    /// The name of the function to call
110    pub name: String,
111
112    /// The arguments to pass to the function, as a JSON string
113    pub arguments: String,
114}
115
116impl ToolCall {
117    /// Create a new function tool call
118    pub fn function(id: String, name: String, arguments: String) -> Self {
119        Self {
120            id,
121            call_type: "function".to_owned(),
122            function: Some(FunctionCall { name, arguments }),
123            text: None,
124            thought_signature: None,
125        }
126    }
127
128    /// Create a new custom tool call with raw text payload (GPT-5 freeform)
129    pub fn custom(id: String, name: String, text: String) -> Self {
130        Self {
131            id,
132            call_type: "custom".to_owned(),
133            function: Some(FunctionCall {
134                name,
135                arguments: text.clone(),
136            }),
137            text: Some(text),
138            thought_signature: None,
139        }
140    }
141
142    /// Parse the arguments as JSON Value (for function-type tools)
143    pub fn parsed_arguments(&self) -> Result<serde_json::Value, serde_json::Error> {
144        if let Some(ref func) = self.function {
145            serde_json::from_str(&func.arguments)
146        } else {
147            // Return an error by trying to parse invalid JSON
148            serde_json::from_str("")
149        }
150    }
151
152    /// Validate that this tool call is properly formed
153    pub fn validate(&self) -> Result<(), String> {
154        if self.id.is_empty() {
155            return Err("Tool call ID cannot be empty".to_owned());
156        }
157
158        match self.call_type.as_str() {
159            "function" => {
160                if let Some(func) = &self.function {
161                    if func.name.is_empty() {
162                        return Err("Function name cannot be empty".to_owned());
163                    }
164                    // Validate that arguments is valid JSON for function tools
165                    if let Err(e) = self.parsed_arguments() {
166                        return Err(format!("Invalid JSON in function arguments: {}", e));
167                    }
168                } else {
169                    return Err("Function tool call missing function details".to_owned());
170                }
171            }
172            "custom" => {
173                // For custom tools, we allow raw text payload without JSON validation
174                if let Some(func) = &self.function {
175                    if func.name.is_empty() {
176                        return Err("Custom tool name cannot be empty".to_owned());
177                    }
178                } else {
179                    return Err("Custom tool call missing function details".to_owned());
180                }
181            }
182            _ => return Err(format!("Unsupported tool call type: {}", self.call_type)),
183        }
184
185        Ok(())
186    }
187}
188
189/// Universal LLM response structure
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct LLMResponse {
192    /// The response content text
193    pub content: Option<String>,
194
195    /// Tool calls made by the model
196    pub tool_calls: Option<Vec<ToolCall>>,
197
198    /// The model that generated this response
199    pub model: String,
200
201    /// Token usage statistics
202    pub usage: Option<Usage>,
203
204    /// Why the response finished
205    pub finish_reason: FinishReason,
206
207    /// Reasoning content (for models that support it)
208    pub reasoning: Option<String>,
209
210    /// Detailed reasoning traces (for models that support it)
211    pub reasoning_details: Option<Vec<String>>,
212
213    /// Tool references for context
214    pub tool_references: Vec<String>,
215
216    /// Request ID from the provider
217    pub request_id: Option<String>,
218
219    /// Organization ID from the provider
220    pub organization_id: Option<String>,
221}
222
223impl LLMResponse {
224    /// Create a new LLM response with mandatory fields
225    pub fn new(model: impl Into<String>, content: impl Into<String>) -> Self {
226        Self {
227            content: Some(content.into()),
228            tool_calls: None,
229            model: model.into(),
230            usage: None,
231            finish_reason: FinishReason::Stop,
232            reasoning: None,
233            reasoning_details: None,
234            tool_references: Vec::new(),
235            request_id: None,
236            organization_id: None,
237        }
238    }
239
240    /// Get content or empty string
241    pub fn content_text(&self) -> &str {
242        self.content.as_deref().unwrap_or("")
243    }
244
245    /// Get content as String (clone)
246    pub fn content_string(&self) -> String {
247        self.content.clone().unwrap_or_default()
248    }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
252pub struct LLMErrorMetadata {
253    pub provider: Option<String>,
254    pub status: Option<u16>,
255    pub code: Option<String>,
256    pub request_id: Option<String>,
257    pub organization_id: Option<String>,
258    pub retry_after: Option<String>,
259    pub message: Option<String>,
260}
261
262impl LLMErrorMetadata {
263    pub fn new(
264        provider: impl Into<String>,
265        status: Option<u16>,
266        code: Option<String>,
267        request_id: Option<String>,
268        organization_id: Option<String>,
269        retry_after: Option<String>,
270        message: Option<String>,
271    ) -> Box<Self> {
272        Box::new(Self {
273            provider: Some(provider.into()),
274            status,
275            code,
276            request_id,
277            organization_id,
278            retry_after,
279            message,
280        })
281    }
282}
283
284/// LLM error types with optional provider metadata
285#[derive(Debug, thiserror::Error, Serialize, Deserialize, Clone)]
286#[serde(tag = "type", rename_all = "snake_case")]
287pub enum LLMError {
288    #[error("Authentication failed: {message}")]
289    Authentication {
290        message: String,
291        metadata: Option<Box<LLMErrorMetadata>>,
292    },
293    #[error("Rate limit exceeded")]
294    RateLimit {
295        metadata: Option<Box<LLMErrorMetadata>>,
296    },
297    #[error("Invalid request: {message}")]
298    InvalidRequest {
299        message: String,
300        metadata: Option<Box<LLMErrorMetadata>>,
301    },
302    #[error("Network error: {message}")]
303    Network {
304        message: String,
305        metadata: Option<Box<LLMErrorMetadata>>,
306    },
307    #[error("Provider error: {message}")]
308    Provider {
309        message: String,
310        metadata: Option<Box<LLMErrorMetadata>>,
311    },
312}