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