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" | "zai" => {
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!(
486                    provider,
487                    "openai" | "openrouter" | "xai" | "deepseek" | "zai"
488                ) && !has_tool_call_id =>
489            {
490                Err(format!("{} tool messages must have tool_call_id", provider))
491            }
492            (MessageRole::Tool, "gemini") if !has_tool_call_id => {
493                Err("Gemini tool messages need tool_call_id for function mapping".to_string())
494            }
495            _ => Ok(()),
496        }
497    }
498}
499
500/// Universal tool definition that matches OpenAI/Anthropic/Gemini specifications
501/// Based on official API documentation from Context7
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct ToolDefinition {
504    /// The type of tool (always "function" for function calling)
505    #[serde(rename = "type")]
506    pub tool_type: String,
507
508    /// Function definition containing name, description, and parameters
509    pub function: FunctionDefinition,
510}
511
512/// Function definition within a tool
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct FunctionDefinition {
515    /// The name of the function to be called
516    pub name: String,
517
518    /// A description of what the function does
519    pub description: String,
520
521    /// The parameters the function accepts, described as a JSON Schema object
522    pub parameters: Value,
523}
524
525impl ToolDefinition {
526    /// Create a new tool definition with function type
527    pub fn function(name: String, description: String, parameters: Value) -> Self {
528        Self {
529            tool_type: "function".to_string(),
530            function: FunctionDefinition {
531                name,
532                description,
533                parameters,
534            },
535        }
536    }
537
538    /// Get the function name for easy access
539    pub fn function_name(&self) -> &str {
540        &self.function.name
541    }
542
543    /// Validate that this tool definition is properly formed
544    pub fn validate(&self) -> Result<(), String> {
545        if self.tool_type != "function" {
546            return Err(format!(
547                "Only 'function' type is supported, got: {}",
548                self.tool_type
549            ));
550        }
551
552        if self.function.name.is_empty() {
553            return Err("Function name cannot be empty".to_string());
554        }
555
556        if self.function.description.is_empty() {
557            return Err("Function description cannot be empty".to_string());
558        }
559
560        // Validate that parameters is a proper JSON Schema object
561        if !self.function.parameters.is_object() {
562            return Err("Function parameters must be a JSON object".to_string());
563        }
564
565        Ok(())
566    }
567}
568
569/// Universal tool call that matches the exact structure from OpenAI API
570/// Based on OpenAI Cookbook examples and official documentation
571#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
572pub struct ToolCall {
573    /// Unique identifier for this tool call (e.g., "call_123")
574    pub id: String,
575
576    /// The type of tool call (always "function" for function calling)
577    #[serde(rename = "type")]
578    pub call_type: String,
579
580    /// Function call details
581    pub function: FunctionCall,
582}
583
584/// Function call within a tool call
585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
586pub struct FunctionCall {
587    /// The name of the function to call
588    pub name: String,
589
590    /// The arguments to pass to the function, as a JSON string
591    pub arguments: String,
592}
593
594impl ToolCall {
595    /// Create a new function tool call
596    pub fn function(id: String, name: String, arguments: String) -> Self {
597        Self {
598            id,
599            call_type: "function".to_string(),
600            function: FunctionCall { name, arguments },
601        }
602    }
603
604    /// Parse the arguments as JSON Value
605    pub fn parsed_arguments(&self) -> Result<Value, serde_json::Error> {
606        serde_json::from_str(&self.function.arguments)
607    }
608
609    /// Validate that this tool call is properly formed
610    pub fn validate(&self) -> Result<(), String> {
611        if self.call_type != "function" {
612            return Err(format!(
613                "Only 'function' type is supported, got: {}",
614                self.call_type
615            ));
616        }
617
618        if self.id.is_empty() {
619            return Err("Tool call ID cannot be empty".to_string());
620        }
621
622        if self.function.name.is_empty() {
623            return Err("Function name cannot be empty".to_string());
624        }
625
626        // Validate that arguments is valid JSON
627        if let Err(e) = self.parsed_arguments() {
628            return Err(format!("Invalid JSON in function arguments: {}", e));
629        }
630
631        Ok(())
632    }
633}
634
635/// Universal LLM response
636#[derive(Debug, Clone)]
637pub struct LLMResponse {
638    pub content: Option<String>,
639    pub tool_calls: Option<Vec<ToolCall>>,
640    pub usage: Option<Usage>,
641    pub finish_reason: FinishReason,
642    pub reasoning: Option<String>,
643}
644
645#[derive(Debug, Clone)]
646pub struct Usage {
647    pub prompt_tokens: u32,
648    pub completion_tokens: u32,
649    pub total_tokens: u32,
650    pub cached_prompt_tokens: Option<u32>,
651    pub cache_creation_tokens: Option<u32>,
652    pub cache_read_tokens: Option<u32>,
653}
654
655#[derive(Debug, Clone, PartialEq, Eq)]
656pub enum FinishReason {
657    Stop,
658    Length,
659    ToolCalls,
660    ContentFilter,
661    Error(String),
662}
663
664#[derive(Debug, Clone)]
665pub enum LLMStreamEvent {
666    Token { delta: String },
667    Reasoning { delta: String },
668    Completed { response: LLMResponse },
669}
670
671pub type LLMStream = Pin<Box<dyn futures::Stream<Item = Result<LLMStreamEvent, LLMError>> + Send>>;
672
673/// Universal LLM provider trait
674#[async_trait]
675pub trait LLMProvider: Send + Sync {
676    /// Provider name (e.g., "gemini", "openai", "anthropic")
677    fn name(&self) -> &str;
678
679    /// Whether the provider has native streaming support
680    fn supports_streaming(&self) -> bool {
681        false
682    }
683
684    /// Whether the provider surfaces structured reasoning traces for the given model
685    fn supports_reasoning(&self, _model: &str) -> bool {
686        false
687    }
688
689    /// Whether the provider accepts configurable reasoning effort for the model
690    fn supports_reasoning_effort(&self, _model: &str) -> bool {
691        false
692    }
693
694    /// Whether the provider supports structured tool calling for the given model
695    fn supports_tools(&self, _model: &str) -> bool {
696        true
697    }
698
699    /// Generate completion
700    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError>;
701
702    /// Stream completion (optional)
703    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
704        // Default implementation falls back to non-streaming
705        let response = self.generate(request).await?;
706        let stream = try_stream! {
707            yield LLMStreamEvent::Completed { response };
708        };
709        Ok(Box::pin(stream))
710    }
711
712    /// Get supported models
713    fn supported_models(&self) -> Vec<String>;
714
715    /// Validate request for this provider
716    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError>;
717}
718
719#[derive(Debug, thiserror::Error)]
720pub enum LLMError {
721    #[error("Authentication failed: {0}")]
722    Authentication(String),
723    #[error("Rate limit exceeded")]
724    RateLimit,
725    #[error("Invalid request: {0}")]
726    InvalidRequest(String),
727    #[error("Network error: {0}")]
728    Network(String),
729    #[error("Provider error: {0}")]
730    Provider(String),
731}
732
733// Implement conversion from provider::LLMError to llm::types::LLMError
734impl From<LLMError> for crate::llm::types::LLMError {
735    fn from(err: LLMError) -> crate::llm::types::LLMError {
736        match err {
737            LLMError::Authentication(msg) => crate::llm::types::LLMError::ApiError(msg),
738            LLMError::RateLimit => crate::llm::types::LLMError::RateLimit,
739            LLMError::InvalidRequest(msg) => crate::llm::types::LLMError::InvalidRequest(msg),
740            LLMError::Network(msg) => crate::llm::types::LLMError::NetworkError(msg),
741            LLMError::Provider(msg) => crate::llm::types::LLMError::ApiError(msg),
742        }
743    }
744}