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