Skip to main content

vtcode_llm/
rig_adapter.rs

1use rig::providers::gemini::completion::gemini_api_types::ThinkingConfig;
2use serde_json::{Value, json};
3use vtcode_config::models::Provider;
4use vtcode_config::types::ReasoningEffortLevel;
5
6/// Internal bridge for provider reasoning-parameter construction.
7#[derive(Debug, Clone)]
8pub struct RigProviderCapabilities {
9    provider: Provider,
10    model: compact_str::CompactString,
11}
12
13impl RigProviderCapabilities {
14    #[must_use]
15    pub fn new(provider: Provider, model: impl Into<String>) -> Self {
16        Self { provider, model: model.into().into() }
17    }
18
19    /// Serialize an already mapped effort without changing its fidelity.
20    /// Unsupported controls are returned as an invalid request so callers
21    /// cannot silently drop or coerce the requested effort.
22    pub fn reasoning_parameters(
23        &self,
24        effort: ReasoningEffortLevel,
25    ) -> Result<Option<Value>, crate::provider::LLMError> {
26        let supported = crate::provider::catalog_reasoning_efforts(self.provider.as_ref(), &self.model)
27            .or_else(|| (self.provider == Provider::Anthropic).then_some(crate::provider::GENERIC_REASONING_EFFORTS))
28            .unwrap_or(&[]);
29        self.reasoning_parameters_for_supported_efforts(effort, supported)
30    }
31
32    /// Serialize an effort against a provider-resolved capability list.
33    ///
34    /// Built-in model routes should use [`Self::reasoning_parameters`], which
35    /// validates against the catalog. Configured custom routes can instead
36    /// pass the Provider trait's advertised levels so strict validation does
37    /// not reject a capability that was explicitly supplied by the user.
38    pub fn reasoning_parameters_for_supported_efforts(
39        &self,
40        effort: ReasoningEffortLevel,
41        supported: &[&str],
42    ) -> Result<Option<Value>, crate::provider::LLMError> {
43        if effort == ReasoningEffortLevel::None {
44            return Ok(None);
45        }
46        let _mapping =
47            crate::reasoning_effort::ReasoningEffortMapper::map(effort, supported, false).map_err(|error| {
48                crate::provider::LLMError::InvalidRequest { message: error.to_string(), metadata: None }
49            })?;
50        let payload = match self.provider {
51            Provider::OpenAI => Some(json!({ "effort": effort.as_str() })),
52            Provider::Gemini => {
53                let budget = match effort {
54                    ReasoningEffortLevel::Minimal => 16,
55                    ReasoningEffortLevel::Low => 64,
56                    ReasoningEffortLevel::Medium => 128,
57                    ReasoningEffortLevel::High => 256,
58                    _ => {
59                        return Err(crate::provider::LLMError::InvalidRequest {
60                            message: format!("Gemini does not serialize reasoning effort `{effort}`"),
61                            metadata: None,
62                        });
63                    }
64                };
65                let config = ThinkingConfig {
66                    thinking_budget: Some(budget),
67                    thinking_level: None,
68                    include_thoughts: Some(effort == ReasoningEffortLevel::High),
69                };
70                serde_json::to_value(config)
71                    .ok()
72                    .map(|value| json!({ "thinking_config": value }))
73            }
74            Provider::DeepSeek | Provider::ZAI => Some(json!({
75                "thinking": { "type": "enabled" }, "reasoning_effort": effort.as_str()
76            })),
77            Provider::HuggingFace | Provider::Meta | Provider::Evolink => {
78                Some(json!({ "reasoning_effort": effort.as_str() }))
79            }
80            // StepFun speaks the native Responses envelope (`reasoning.effort`).
81            Provider::StepFun => Some(json!({ "reasoning": { "effort": effort.as_str() } })),
82            // OpenRouter follows the OpenAI-compatible `reasoning.effort`
83            // envelope, including for custom routes whose capabilities are
84            // supplied by the Provider trait rather than the built-in catalog.
85            Provider::OpenRouter => Some(json!({ "effort": effort.as_str() })),
86            Provider::Anthropic => None,
87            _ => None,
88        };
89        Ok(payload)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn rig_serialization_preserves_exact_catalog_effort() {
99        for (provider, model, key) in [
100            (Provider::OpenAI, "gpt-6-astra", "effort"),
101            (Provider::ZAI, "glm-5.3", "reasoning_effort"),
102        ] {
103            for effort in [
104                ReasoningEffortLevel::Low,
105                ReasoningEffortLevel::High,
106                ReasoningEffortLevel::Max,
107            ] {
108                let payload = RigProviderCapabilities::new(provider, model)
109                    .reasoning_parameters(effort)
110                    .expect("reasoning serialization should not fail")
111                    .expect("catalog-supported effort");
112                assert_eq!(payload[key], effort.as_str());
113            }
114        }
115    }
116
117    #[test]
118    fn rig_serialization_rejects_unsupported_effort_without_aliasing() {
119        for (provider, model, effort) in [
120            (Provider::OpenAI, "gpt-6-astra", ReasoningEffortLevel::Minimal),
121            (Provider::ZAI, "glm-5.3", ReasoningEffortLevel::XHigh),
122            (Provider::Gemini, "gemini-3.7-flash", ReasoningEffortLevel::Max),
123            (Provider::OpenAI, "unknown-model", ReasoningEffortLevel::High),
124            (Provider::OpenRouter, "meta/muse-spark-1.2", ReasoningEffortLevel::High),
125        ] {
126            assert!(
127                RigProviderCapabilities::new(provider, model)
128                    .reasoning_parameters(effort)
129                    .is_err()
130            );
131        }
132    }
133
134    #[test]
135    fn rig_serialization_accepts_explicit_custom_capability_levels() {
136        let payload = RigProviderCapabilities::new(Provider::OpenAI, "custom-route")
137            .reasoning_parameters_for_supported_efforts(ReasoningEffortLevel::High, &["low", "medium", "high"])
138            .expect("explicit provider capability should validate")
139            .expect("OpenRouter should serialize reasoning effort");
140        assert_eq!(payload["effort"], "high");
141    }
142
143    #[test]
144    fn anthropic_custom_routes_use_generic_efforts_without_silent_drops() {
145        let payload = RigProviderCapabilities::new(Provider::Anthropic, "custom-route")
146            .reasoning_parameters(ReasoningEffortLevel::High)
147            .expect("generic custom effort should validate");
148        assert!(payload.is_none(), "Anthropic encodes this effort in output_config");
149        assert!(
150            RigProviderCapabilities::new(Provider::Anthropic, "custom-route")
151                .reasoning_parameters(ReasoningEffortLevel::Max)
152                .is_err()
153        );
154    }
155
156    #[test]
157    fn native_providers_do_not_fail_rig_serialization() {
158        for (provider, model) in [
159            (Provider::MergeGateway, "openai/gpt-5.5"),
160            (Provider::OllamaCloud, "gpt-oss:120b-cloud"),
161        ] {
162            let payload = RigProviderCapabilities::new(provider, model)
163                .reasoning_parameters_for_supported_efforts(ReasoningEffortLevel::High, &["low", "medium", "high"])
164                .expect("native provider should not error on supported effort");
165            assert!(payload.is_none(), "native provider handles reasoning effort on its own");
166        }
167    }
168
169    #[test]
170    fn all_providers_handle_reasoning_parameters_gracefully() {
171        let supported_efforts = &["low", "medium", "high", "xhigh", "max"];
172        for (provider, model) in [
173            (Provider::OpenAI, "gpt-5.1-mini"),
174            (Provider::Anthropic, "claude-sonnet-5"),
175            (Provider::Gemini, "gemini-3.7-flash"),
176            (Provider::DeepSeek, "deepseek-v4.1-flash"),
177            (Provider::ZAI, "glm-5.3-flash"),
178            (Provider::Meta, "muse-spark-1.3"),
179            (Provider::OpenRouter, "openrouter/auto"),
180            (Provider::Ollama, "gpt-oss:20b"),
181            (Provider::OllamaCloud, "gpt-oss:20b-cloud"),
182            (Provider::LmStudio, "gpt-oss-20b"),
183            (Provider::LlamaCpp, "gpt-oss-20b"),
184            (Provider::Moonshot, "kimi-k3"),
185            (Provider::Minimax, "minimax-m2.7"),
186            (Provider::MiMo, "mimo-v2.6-pro"),
187            (Provider::Mistral, "ministral-3-3b-2512"),
188            (Provider::HuggingFace, "openai/gpt-oss-20b:huggingface"),
189            (Provider::OpenCodeZen, "gpt-5.6-sol"),
190            (Provider::OpenCodeGo, "glm-5.2"),
191            (Provider::Qwen, "deepseek-flash"),
192            (Provider::StepFun, "step-3.7-flash"),
193            (Provider::Evolink, "deepseek-flash"),
194            (Provider::Poolside, "laguna-xs2"),
195            (Provider::XAI, "grok-4-6"),
196            (Provider::NVIDIA, "nvidia/nemotron-3-nano-30b-a3b"),
197            (Provider::MergeGateway, "openai/gpt-5.5"),
198            (Provider::Vercel, "claude-sonnet-5"),
199        ] {
200            let result = RigProviderCapabilities::new(provider, model)
201                .reasoning_parameters_for_supported_efforts(ReasoningEffortLevel::High, supported_efforts);
202            assert!(result.is_ok(), "provider {provider:?} with model {model} should not error on supported effort");
203        }
204    }
205}