vtcode_core/llm/
provider.rs

1//! Universal LLM provider abstraction with API-specific role handling
2//!
3//! This module provides a unified interface for different LLM providers (OpenAI, Anthropic, Gemini)
4//! while properly handling their specific requirements for message roles and tool calling.
5//!
6//! ## Message Role Mapping
7//!
8//! Different LLM providers have varying support for message roles, especially for tool calling:
9//!
10//! ### OpenAI API
11//! - **Full Support**: `system`, `user`, `assistant`, `tool`
12//! - **Tool Messages**: Must include `tool_call_id` to reference the original tool call
13//! - **Tool Calls**: Only `assistant` messages can contain `tool_calls`
14//!
15//! ### Anthropic API
16//! - **Standard Roles**: `user`, `assistant`
17//! - **System Messages**: Can be hoisted to system parameter or treated as user messages
18//! - **Tool Responses**: Converted to `user` messages (no separate tool role)
19//! - **Tool Choice**: Supports `auto`, `any`, `tool`, `none` modes
20//!
21//! ### Gemini API
22//! - **Conversation Roles**: Only `user` and `model` (not `assistant`)
23//! - **System Messages**: Handled separately as `systemInstruction` parameter
24//! - **Tool Responses**: Converted to `user` messages with `functionResponse` format
25//! - **Function Calls**: Uses `functionCall` in `model` messages
26//!
27//! ## Best Practices
28//!
29//! 1. Always use `MessageRole::tool_response()` constructor for tool responses
30//! 2. Validate messages using `validate_for_provider()` before sending
31//! 3. Use appropriate role mapping methods for each provider
32//! 4. Handle provider-specific constraints (e.g., Gemini's system instruction requirement)
33//!
34//! ## Example Usage
35//!
36//! ```rust
37//! use vtcode_core::llm::provider::{Message, MessageRole};
38//!
39//! // Create a proper tool response message
40//! let tool_response = Message::tool_response(
41//!     "call_123".to_string(),
42//!     "Tool execution completed successfully".to_string()
43//! );
44//!
45//! // Validate for specific provider
46//! tool_response.validate_for_provider("openai").unwrap();
47//! ```
48
49use async_trait::async_trait;
50use serde::{Deserialize, Serialize};
51use serde_json::{Value, json};
52
53/// Universal LLM request structure
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct LLMRequest {
56    pub messages: Vec<Message>,
57    pub system_prompt: Option<String>,
58    pub tools: Option<Vec<ToolDefinition>>,
59    pub model: String,
60    pub max_tokens: Option<u32>,
61    pub temperature: Option<f32>,
62    pub stream: bool,
63
64    /// Tool choice configuration based on official API docs
65    /// Supports: "auto" (default), "none", "any", or specific tool selection
66    pub tool_choice: Option<ToolChoice>,
67
68    /// Whether to enable parallel tool calls (OpenAI specific)
69    pub parallel_tool_calls: Option<bool>,
70
71    /// Parallel tool use configuration following Anthropic best practices
72    pub parallel_tool_config: Option<ParallelToolConfig>,
73
74    /// Reasoning effort level for models that support it (low, medium, high)
75    /// Applies to: Claude, GPT-5, Gemini, Qwen3, DeepSeek with reasoning capability
76    pub reasoning_effort: Option<String>,
77}
78
79/// Tool choice configuration that works across different providers
80/// Based on OpenAI, Anthropic, and Gemini API specifications
81/// Follows Anthropic's tool use best practices for optimal performance
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(untagged)]
84pub enum ToolChoice {
85    /// Let the model decide whether to call tools ("auto")
86    /// Default behavior - allows model to use tools when appropriate
87    Auto,
88
89    /// Force the model to not call any tools ("none")
90    /// Useful for pure conversational responses without tool usage
91    None,
92
93    /// Force the model to call at least one tool ("any")
94    /// Ensures tool usage even when model might prefer direct response
95    Any,
96
97    /// Force the model to call a specific tool
98    /// Useful for directing model to use particular functionality
99    Specific(SpecificToolChoice),
100}
101
102/// Specific tool choice for forcing a particular function call
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct SpecificToolChoice {
105    #[serde(rename = "type")]
106    pub tool_type: String, // "function"
107
108    pub function: SpecificFunctionChoice,
109}
110
111/// Specific function choice details
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct SpecificFunctionChoice {
114    pub name: String,
115}
116
117impl ToolChoice {
118    /// Create auto tool choice (default behavior)
119    pub fn auto() -> Self {
120        Self::Auto
121    }
122
123    /// Create none tool choice (disable tool calling)
124    pub fn none() -> Self {
125        Self::None
126    }
127
128    /// Create any tool choice (force at least one tool call)
129    pub fn any() -> Self {
130        Self::Any
131    }
132
133    /// Create specific function tool choice
134    pub fn function(name: String) -> Self {
135        Self::Specific(SpecificToolChoice {
136            tool_type: "function".to_string(),
137            function: SpecificFunctionChoice { name },
138        })
139    }
140
141    /// Check if this tool choice allows parallel tool use
142    /// Based on Anthropic's parallel tool use guidelines
143    pub fn allows_parallel_tools(&self) -> bool {
144        match self {
145            // Auto allows parallel tools by default
146            Self::Auto => true,
147            // Any forces at least one tool, may allow parallel
148            Self::Any => true,
149            // Specific forces one particular tool, typically no parallel
150            Self::Specific(_) => false,
151            // None disables tools entirely
152            Self::None => false,
153        }
154    }
155
156    /// Get human-readable description of tool choice behavior
157    pub fn description(&self) -> &'static str {
158        match self {
159            Self::Auto => "Model decides when to use tools (allows parallel)",
160            Self::None => "No tools will be used",
161            Self::Any => "At least one tool must be used (allows parallel)",
162            Self::Specific(_) => "Specific tool must be used (no parallel)",
163        }
164    }
165
166    /// Convert to provider-specific format
167    pub fn to_provider_format(&self, provider: &str) -> Value {
168        match (self, provider) {
169            (Self::Auto, "openai") => json!("auto"),
170            (Self::None, "openai") => json!("none"),
171            (Self::Any, "openai") => json!("required"), // OpenAI uses "required" instead of "any"
172            (Self::Specific(choice), "openai") => json!(choice),
173
174            (Self::Auto, "anthropic") => json!({"type": "auto"}),
175            (Self::None, "anthropic") => json!({"type": "none"}),
176            (Self::Any, "anthropic") => json!({"type": "any"}),
177            (Self::Specific(choice), "anthropic") => {
178                json!({"type": "tool", "name": choice.function.name})
179            }
180
181            (Self::Auto, "gemini") => json!({"mode": "auto"}),
182            (Self::None, "gemini") => json!({"mode": "none"}),
183            (Self::Any, "gemini") => json!({"mode": "any"}),
184            (Self::Specific(choice), "gemini") => {
185                json!({"mode": "any", "allowed_function_names": [choice.function.name]})
186            }
187
188            // Generic follows OpenAI format
189            _ => match self {
190                Self::Auto => json!("auto"),
191                Self::None => json!("none"),
192                Self::Any => json!("required"),
193                Self::Specific(choice) => json!(choice),
194            },
195        }
196    }
197}
198
199/// Configuration for parallel tool use behavior
200/// Based on Anthropic's parallel tool use guidelines
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ParallelToolConfig {
203    /// Whether to disable parallel tool use
204    /// When true, forces sequential tool execution
205    pub disable_parallel_tool_use: bool,
206
207    /// Maximum number of tools to execute in parallel
208    /// None means no limit (provider default)
209    pub max_parallel_tools: Option<usize>,
210
211    /// Whether to encourage parallel tool use in prompts
212    pub encourage_parallel: bool,
213}
214
215impl Default for ParallelToolConfig {
216    fn default() -> Self {
217        Self {
218            disable_parallel_tool_use: false,
219            max_parallel_tools: Some(5), // Reasonable default
220            encourage_parallel: true,
221        }
222    }
223}
224
225impl ParallelToolConfig {
226    /// Create configuration optimized for Anthropic models
227    pub fn anthropic_optimized() -> Self {
228        Self {
229            disable_parallel_tool_use: false,
230            max_parallel_tools: None, // Let Anthropic decide
231            encourage_parallel: true,
232        }
233    }
234
235    /// Create configuration for sequential tool use
236    pub fn sequential_only() -> Self {
237        Self {
238            disable_parallel_tool_use: true,
239            max_parallel_tools: Some(1),
240            encourage_parallel: false,
241        }
242    }
243}
244
245/// Universal message structure
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct Message {
248    pub role: MessageRole,
249    pub content: String,
250    pub tool_calls: Option<Vec<ToolCall>>,
251    pub tool_call_id: Option<String>,
252}
253
254impl Message {
255    /// Create a user message
256    pub fn user(content: String) -> Self {
257        Self {
258            role: MessageRole::User,
259            content,
260            tool_calls: None,
261            tool_call_id: None,
262        }
263    }
264
265    /// Create an assistant message
266    pub fn assistant(content: String) -> Self {
267        Self {
268            role: MessageRole::Assistant,
269            content,
270            tool_calls: None,
271            tool_call_id: None,
272        }
273    }
274
275    /// Create an assistant message with tool calls
276    /// Based on OpenAI Cookbook patterns for function calling
277    pub fn assistant_with_tools(content: String, tool_calls: Vec<ToolCall>) -> Self {
278        Self {
279            role: MessageRole::Assistant,
280            content,
281            tool_calls: Some(tool_calls),
282            tool_call_id: None,
283        }
284    }
285
286    /// Create a system message
287    pub fn system(content: String) -> Self {
288        Self {
289            role: MessageRole::System,
290            content,
291            tool_calls: None,
292            tool_call_id: None,
293        }
294    }
295
296    /// Create a tool response message
297    /// This follows the exact pattern from OpenAI Cookbook:
298    /// ```json
299    /// {
300    ///   "role": "tool",
301    ///   "tool_call_id": "call_123",
302    ///   "content": "Function result"
303    /// }
304    /// ```
305    pub fn tool_response(tool_call_id: String, content: String) -> Self {
306        Self {
307            role: MessageRole::Tool,
308            content,
309            tool_calls: None,
310            tool_call_id: Some(tool_call_id),
311        }
312    }
313
314    /// Create a tool response message with function name (for compatibility)
315    /// Some providers might need the function name in addition to tool_call_id
316    pub fn tool_response_with_name(
317        tool_call_id: String,
318        _function_name: String,
319        content: String,
320    ) -> Self {
321        // We can store the function name in the content metadata or handle it provider-specifically
322        Self::tool_response(tool_call_id, content)
323    }
324
325    /// Validate this message for a specific provider
326    /// Based on official API documentation constraints
327    pub fn validate_for_provider(&self, provider: &str) -> Result<(), String> {
328        // Check role-specific constraints
329        self.role
330            .validate_for_provider(provider, self.tool_call_id.is_some())?;
331
332        // Check tool call constraints
333        if let Some(tool_calls) = &self.tool_calls {
334            if !self.role.can_make_tool_calls() {
335                return Err(format!("Role {:?} cannot make tool calls", self.role));
336            }
337
338            if tool_calls.is_empty() {
339                return Err("Tool calls array should not be empty".to_string());
340            }
341
342            // Validate each tool call
343            for tool_call in tool_calls {
344                tool_call.validate()?;
345            }
346        }
347
348        // Provider-specific validations based on official docs
349        match provider {
350            "openai" => {
351                if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
352                    return Err(format!(
353                        "{} requires tool_call_id for tool messages",
354                        provider
355                    ));
356                }
357            }
358            "gemini" => {
359                if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
360                    return Err(
361                        "Gemini tool responses need tool_call_id for function name mapping"
362                            .to_string(),
363                    );
364                }
365                // Gemini has additional constraints on content structure
366                if self.role == MessageRole::System && !self.content.is_empty() {
367                    // System messages should be handled as systemInstruction, not in contents
368                }
369            }
370            "anthropic" => {
371                // Anthropic is more flexible with tool message format
372                // Tool messages are converted to user messages anyway
373            }
374            _ => {} // Generic validation already done above
375        }
376
377        Ok(())
378    }
379
380    /// Check if this message has tool calls
381    pub fn has_tool_calls(&self) -> bool {
382        self.tool_calls
383            .as_ref()
384            .map_or(false, |calls| !calls.is_empty())
385    }
386
387    /// Get the tool calls if present
388    pub fn get_tool_calls(&self) -> Option<&[ToolCall]> {
389        self.tool_calls.as_deref()
390    }
391
392    /// Check if this is a tool response message
393    pub fn is_tool_response(&self) -> bool {
394        self.role == MessageRole::Tool
395    }
396}
397
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399pub enum MessageRole {
400    System,
401    User,
402    Assistant,
403    Tool,
404}
405
406impl MessageRole {
407    /// Get the role string for Gemini API
408    /// Note: Gemini API has specific constraints on message roles
409    /// - Only accepts "user" and "model" roles in conversations
410    /// - System messages are handled separately as system instructions
411    /// - Tool responses are sent as "user" role with function response format
412    pub fn as_gemini_str(&self) -> &'static str {
413        match self {
414            MessageRole::System => "system", // Handled as systemInstruction, not in contents
415            MessageRole::User => "user",
416            MessageRole::Assistant => "model", // Gemini uses "model" instead of "assistant"
417            MessageRole::Tool => "user", // Tool responses are sent as user messages with functionResponse
418        }
419    }
420
421    /// Get the role string for OpenAI API
422    /// OpenAI supports all standard role types including:
423    /// - system, user, assistant, tool
424    /// - function (legacy, now replaced by tool)
425    pub fn as_openai_str(&self) -> &'static str {
426        match self {
427            MessageRole::System => "system",
428            MessageRole::User => "user",
429            MessageRole::Assistant => "assistant",
430            MessageRole::Tool => "tool", // Full support for tool role with tool_call_id
431        }
432    }
433
434    /// Get the role string for Anthropic API
435    /// Anthropic has specific handling for tool messages:
436    /// - Supports user, assistant roles normally
437    /// - Tool responses are treated as user messages
438    /// - System messages can be handled as system parameter or hoisted
439    pub fn as_anthropic_str(&self) -> &'static str {
440        match self {
441            MessageRole::System => "system", // Can be hoisted to system parameter
442            MessageRole::User => "user",
443            MessageRole::Assistant => "assistant",
444            MessageRole::Tool => "user", // Anthropic treats tool responses as user messages
445        }
446    }
447
448    /// Get the role string for generic OpenAI-compatible providers
449    /// Most providers follow OpenAI's role conventions
450    pub fn as_generic_str(&self) -> &'static str {
451        match self {
452            MessageRole::System => "system",
453            MessageRole::User => "user",
454            MessageRole::Assistant => "assistant",
455            MessageRole::Tool => "tool",
456        }
457    }
458
459    /// Check if this role supports tool calls
460    /// Only Assistant role can initiate tool calls in most APIs
461    pub fn can_make_tool_calls(&self) -> bool {
462        matches!(self, MessageRole::Assistant)
463    }
464
465    /// Check if this role represents a tool response
466    pub fn is_tool_response(&self) -> bool {
467        matches!(self, MessageRole::Tool)
468    }
469
470    /// Validate message role constraints for a given provider
471    /// Based on official API documentation requirements
472    pub fn validate_for_provider(
473        &self,
474        provider: &str,
475        has_tool_call_id: bool,
476    ) -> Result<(), String> {
477        match (self, provider) {
478            (MessageRole::Tool, "openai") if !has_tool_call_id => {
479                Err("OpenAI tool messages must have tool_call_id".to_string())
480            }
481            (MessageRole::Tool, "gemini") if !has_tool_call_id => {
482                Err("Gemini tool messages need tool_call_id for function mapping".to_string())
483            }
484            _ => Ok(()),
485        }
486    }
487}
488
489/// Universal tool definition that matches OpenAI/Anthropic/Gemini specifications
490/// Based on official API documentation from Context7
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct ToolDefinition {
493    /// The type of tool (always "function" for function calling)
494    #[serde(rename = "type")]
495    pub tool_type: String,
496
497    /// Function definition containing name, description, and parameters
498    pub function: FunctionDefinition,
499}
500
501/// Function definition within a tool
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct FunctionDefinition {
504    /// The name of the function to be called
505    pub name: String,
506
507    /// A description of what the function does
508    pub description: String,
509
510    /// The parameters the function accepts, described as a JSON Schema object
511    pub parameters: Value,
512}
513
514impl ToolDefinition {
515    /// Create a new tool definition with function type
516    pub fn function(name: String, description: String, parameters: Value) -> Self {
517        Self {
518            tool_type: "function".to_string(),
519            function: FunctionDefinition {
520                name,
521                description,
522                parameters,
523            },
524        }
525    }
526
527    /// Get the function name for easy access
528    pub fn function_name(&self) -> &str {
529        &self.function.name
530    }
531
532    /// Validate that this tool definition is properly formed
533    pub fn validate(&self) -> Result<(), String> {
534        if self.tool_type != "function" {
535            return Err(format!(
536                "Only 'function' type is supported, got: {}",
537                self.tool_type
538            ));
539        }
540
541        if self.function.name.is_empty() {
542            return Err("Function name cannot be empty".to_string());
543        }
544
545        if self.function.description.is_empty() {
546            return Err("Function description cannot be empty".to_string());
547        }
548
549        // Validate that parameters is a proper JSON Schema object
550        if !self.function.parameters.is_object() {
551            return Err("Function parameters must be a JSON object".to_string());
552        }
553
554        Ok(())
555    }
556}
557
558/// Universal tool call that matches the exact structure from OpenAI API
559/// Based on OpenAI Cookbook examples and official documentation
560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
561pub struct ToolCall {
562    /// Unique identifier for this tool call (e.g., "call_123")
563    pub id: String,
564
565    /// The type of tool call (always "function" for function calling)
566    #[serde(rename = "type")]
567    pub call_type: String,
568
569    /// Function call details
570    pub function: FunctionCall,
571}
572
573/// Function call within a tool call
574#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
575pub struct FunctionCall {
576    /// The name of the function to call
577    pub name: String,
578
579    /// The arguments to pass to the function, as a JSON string
580    pub arguments: String,
581}
582
583impl ToolCall {
584    /// Create a new function tool call
585    pub fn function(id: String, name: String, arguments: String) -> Self {
586        Self {
587            id,
588            call_type: "function".to_string(),
589            function: FunctionCall { name, arguments },
590        }
591    }
592
593    /// Parse the arguments as JSON Value
594    pub fn parsed_arguments(&self) -> Result<Value, serde_json::Error> {
595        serde_json::from_str(&self.function.arguments)
596    }
597
598    /// Validate that this tool call is properly formed
599    pub fn validate(&self) -> Result<(), String> {
600        if self.call_type != "function" {
601            return Err(format!(
602                "Only 'function' type is supported, got: {}",
603                self.call_type
604            ));
605        }
606
607        if self.id.is_empty() {
608            return Err("Tool call ID cannot be empty".to_string());
609        }
610
611        if self.function.name.is_empty() {
612            return Err("Function name cannot be empty".to_string());
613        }
614
615        // Validate that arguments is valid JSON
616        if let Err(e) = self.parsed_arguments() {
617            return Err(format!("Invalid JSON in function arguments: {}", e));
618        }
619
620        Ok(())
621    }
622}
623
624/// Universal LLM response
625#[derive(Debug, Clone)]
626pub struct LLMResponse {
627    pub content: Option<String>,
628    pub tool_calls: Option<Vec<ToolCall>>,
629    pub usage: Option<Usage>,
630    pub finish_reason: FinishReason,
631}
632
633#[derive(Debug, Clone)]
634pub struct Usage {
635    pub prompt_tokens: u32,
636    pub completion_tokens: u32,
637    pub total_tokens: u32,
638}
639
640#[derive(Debug, Clone)]
641pub enum FinishReason {
642    Stop,
643    Length,
644    ToolCalls,
645    ContentFilter,
646    Error(String),
647}
648
649/// Universal LLM provider trait
650#[async_trait]
651pub trait LLMProvider: Send + Sync {
652    /// Provider name (e.g., "gemini", "openai", "anthropic")
653    fn name(&self) -> &str;
654
655    /// Generate completion
656    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError>;
657
658    /// Stream completion (optional)
659    async fn stream(
660        &self,
661        request: LLMRequest,
662    ) -> Result<Box<dyn futures::Stream<Item = LLMResponse> + Unpin + Send>, LLMError> {
663        // Default implementation falls back to non-streaming
664        let response = self.generate(request).await?;
665        Ok(Box::new(futures::stream::once(async { response }).boxed()))
666    }
667
668    /// Get supported models
669    fn supported_models(&self) -> Vec<String>;
670
671    /// Validate request for this provider
672    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError>;
673}
674
675#[derive(Debug, thiserror::Error)]
676pub enum LLMError {
677    #[error("Authentication failed: {0}")]
678    Authentication(String),
679    #[error("Rate limit exceeded")]
680    RateLimit,
681    #[error("Invalid request: {0}")]
682    InvalidRequest(String),
683    #[error("Network error: {0}")]
684    Network(String),
685    #[error("Provider error: {0}")]
686    Provider(String),
687}
688
689// Implement conversion from provider::LLMError to llm::types::LLMError
690impl From<LLMError> for crate::llm::types::LLMError {
691    fn from(err: LLMError) -> crate::llm::types::LLMError {
692        match err {
693            LLMError::Authentication(msg) => crate::llm::types::LLMError::ApiError(msg),
694            LLMError::RateLimit => crate::llm::types::LLMError::RateLimit,
695            LLMError::InvalidRequest(msg) => crate::llm::types::LLMError::InvalidRequest(msg),
696            LLMError::Network(msg) => crate::llm::types::LLMError::NetworkError(msg),
697            LLMError::Provider(msg) => crate::llm::types::LLMError::ApiError(msg),
698        }
699    }
700}
701
702use futures::StreamExt;