Skip to main content

vtcode_llm/providers/openrouter/provider/
mod.rs

1#![allow(
2    clippy::collapsible_if,
3    reason = "Intentional compatibility, platform, or test-only suppression."
4)]
5
6use tracing::warn;
7
8use crate::error_display;
9use crate::provider::{LLMError, LLMRequest, Message, MessageRole, ToolChoice};
10use crate::providers::common::{chat_completions_url, extract_prompt_cache_settings, override_base_url, resolve_model};
11use crate::providers::error_handling::format_network_error;
12use reqwest::{Client as HttpClient, Response, StatusCode};
13use serde_json::Value;
14use std::borrow::Cow;
15use std::str::FromStr;
16use vtcode_commons::sanitizer::sanitize_provider_diagnostic;
17use vtcode_config::TimeoutsConfig;
18use vtcode_config::constants::{env_vars, models, urls};
19use vtcode_config::core::{AnthropicConfig, ModelConfig, OpenRouterPromptCacheSettings, PromptCachingConfig};
20use vtcode_config::models::ModelId;
21
22const OPENROUTER_REFERER: &str = "https://github.com/vinhnx/vtcode";
23const OPENROUTER_TITLE: &str = "VT Code";
24const OPENROUTER_CATEGORIES: &str = "agents,coding";
25
26/// OpenRouter sticky-routing session identity: body `session_id` (docs: body wins)
27/// paired with `x-session-id` at dispatch. Blank lineage omits both.
28fn inject_openrouter_session_identity(payload: &mut Value, session_id: Option<&str>) {
29    if let Some(session_id) = session_id.filter(|s| !s.trim().is_empty())
30        && let Some(map) = payload.as_object_mut()
31    {
32        map.insert("session_id".to_owned(), Value::String(session_id.to_owned()));
33    }
34}
35
36mod client_impl;
37mod parsing;
38mod provider_impl;
39#[cfg(test)]
40mod tests;
41
42pub struct OpenRouterProvider {
43    api_key: String,
44    http_client: HttpClient,
45    base_url: String,
46    model: String,
47    prompt_cache_enabled: bool,
48    prompt_cache_settings: OpenRouterPromptCacheSettings,
49    model_behavior: Option<ModelConfig>,
50}
51
52impl OpenRouterProvider {
53    pub fn new(api_key: String) -> Self {
54        Self::with_model_internal(
55            api_key,
56            models::openrouter::DEFAULT_MODEL.to_string(),
57            None,
58            None,
59            TimeoutsConfig::default(),
60            None,
61        )
62    }
63
64    fn with_model(api_key: String, model: String) -> Self {
65        Self::with_model_internal(api_key, model, None, None, TimeoutsConfig::default(), None)
66    }
67
68    fn new_with_client(
69        api_key: String,
70        model: String,
71        http_client: reqwest::Client,
72        base_url: String,
73        _timeouts: TimeoutsConfig,
74    ) -> Self {
75        Self {
76            api_key,
77            http_client,
78            base_url,
79            model,
80            prompt_cache_enabled: false,
81            prompt_cache_settings: OpenRouterPromptCacheSettings::default(),
82            model_behavior: None,
83        }
84    }
85
86    pub fn from_config(
87        api_key: Option<String>,
88        model: Option<String>,
89        base_url: Option<String>,
90        prompt_cache: Option<PromptCachingConfig>,
91        timeouts: Option<TimeoutsConfig>,
92        _anthropic: Option<AnthropicConfig>,
93        model_behavior: Option<ModelConfig>,
94    ) -> Self {
95        let api_key_value = api_key.unwrap_or_default();
96        let model_value = resolve_model(model, models::openrouter::DEFAULT_MODEL);
97
98        Self::with_model_internal(
99            api_key_value,
100            model_value,
101            prompt_cache,
102            base_url,
103            timeouts.unwrap_or_default(),
104            model_behavior,
105        )
106    }
107
108    fn with_model_internal(
109        api_key: String,
110        model: String,
111        prompt_cache: Option<PromptCachingConfig>,
112        base_url: Option<String>,
113        timeouts: TimeoutsConfig,
114        model_behavior: Option<ModelConfig>,
115    ) -> Self {
116        use crate::http_client::HttpClientFactory;
117        let (prompt_cache_enabled, prompt_cache_settings) = extract_prompt_cache_settings(
118            prompt_cache,
119            |p| &p.openrouter,
120            |cfg, settings| cfg.enabled && settings.enabled,
121        );
122
123        Self {
124            api_key,
125            http_client: HttpClientFactory::for_llm(&timeouts),
126            base_url: override_base_url(urls::OPENROUTER_API_BASE, base_url, Some(env_vars::OPENROUTER_BASE_URL)),
127            model,
128            prompt_cache_enabled,
129            prompt_cache_settings,
130            model_behavior,
131        }
132    }
133
134    pub(super) fn resolve_model<'a>(&'a self, request: &'a LLMRequest) -> &'a str {
135        if request.model.trim().is_empty() {
136            self.model.as_str()
137        } else {
138            request.model.as_str()
139        }
140    }
141
142    fn request_includes_tools(request: &LLMRequest) -> bool {
143        request.tools.as_ref().map(|tools| !tools.is_empty()).unwrap_or(false)
144    }
145
146    fn enforce_tool_capabilities<'a>(&'a self, request: &'a LLMRequest) -> Cow<'a, LLMRequest> {
147        let resolved_model = self.resolve_model(request);
148        let tools_requested = Self::request_includes_tools(request);
149        let tool_restricted = if let Ok(model_id) = ModelId::from_str(resolved_model) {
150            !model_id.supports_tool_calls()
151        } else {
152            models::openrouter::TOOL_UNAVAILABLE_MODELS.contains(&resolved_model)
153        };
154
155        if tools_requested && tool_restricted {
156            Cow::Owned(Self::tool_free_request(request))
157        } else {
158            Cow::Borrowed(request)
159        }
160    }
161
162    fn tool_free_request(original: &LLMRequest) -> LLMRequest {
163        let mut sanitized = original.clone();
164        sanitized.tools = None;
165        sanitized.tool_choice = Some(ToolChoice::None);
166        sanitized.parallel_tool_calls = None;
167        sanitized.parallel_tool_config = None;
168
169        let mut normalized_messages: Vec<Message> = Vec::with_capacity(original.messages.len());
170
171        for message in original.messages.iter() {
172            match message.role {
173                MessageRole::Assistant => {
174                    let mut cleaned = message.clone();
175                    cleaned.tool_calls = None;
176                    cleaned.tool_call_id = None;
177
178                    let content_text = cleaned.content.as_text();
179                    let has_content = !content_text.trim().is_empty();
180                    if has_content || cleaned.reasoning.is_some() {
181                        normalized_messages.push(cleaned);
182                    }
183                }
184                MessageRole::Tool => {
185                    let content_text = message.content.as_text();
186                    if content_text.trim().is_empty() {
187                        continue;
188                    }
189
190                    let mut converted = Message::user(content_text.into_owned());
191                    converted.reasoning = message.reasoning.clone();
192                    normalized_messages.push(converted);
193                }
194                _ => {
195                    normalized_messages.push(message.clone());
196                }
197            }
198        }
199
200        sanitized.messages = std::sync::Arc::new(normalized_messages);
201        sanitized
202    }
203
204    fn request_includes_images(request: &LLMRequest) -> bool {
205        request.messages.iter().any(|msg| msg.content.has_images())
206    }
207
208    fn image_free_request(original: &LLMRequest) -> LLMRequest {
209        let mut sanitized = original.clone();
210        for message in std::sync::Arc::make_mut(&mut sanitized.messages) {
211            if let Some(text_only) = message.content.without_images() {
212                message.content = text_only;
213            }
214        }
215        sanitized
216    }
217
218    /// Retry a request with a fallback payload. Returns `Ok(Some(response))` on
219    /// success, `Err` on rate-limit, and `Ok(None)` when the fallback also fails
220    /// (caller assembles the combined error).
221    async fn retry_with_fallback(
222        &self,
223        original_status: StatusCode,
224        original_error: &str,
225        fallback_request: &LLMRequest,
226        stream_override: Option<bool>,
227        label: &str,
228    ) -> Result<Option<Response>, LLMError> {
229        let (mut fallback_payload, fallback_url) = self.build_provider_payload(fallback_request)?;
230        if let Some(stream_flag) = stream_override {
231            fallback_payload["stream"] = Value::Bool(stream_flag);
232        }
233        let fallback_session_id = crate::providers::shared::session_lineage_from_prompt_cache_key(
234            fallback_request.prompt_cache_key.as_deref(),
235        );
236        inject_openrouter_session_identity(&mut fallback_payload, fallback_session_id.as_deref());
237
238        let fallback_response = self
239            .dispatch_request_with_session(&fallback_url, &fallback_payload, fallback_session_id.as_deref())
240            .await?;
241        if fallback_response.status().is_success() {
242            return Ok(Some(fallback_response));
243        }
244
245        let fallback_status = fallback_response.status();
246        let fallback_text = crate::providers::common::read_provider_error_body(fallback_response).await;
247
248        if fallback_status.as_u16() == 429 || fallback_text.contains("quota") {
249            return Err(LLMError::RateLimit { metadata: None });
250        }
251
252        let combined_error = format!(
253            "HTTP {original_status}: {} | {label} fallback failed with HTTP {fallback_status}: {}",
254            sanitize_provider_diagnostic(original_error.as_bytes()),
255            sanitize_provider_diagnostic(fallback_text.as_bytes()),
256        );
257        let formatted_error = error_display::format_llm_error("OpenRouter", &combined_error);
258        Err(LLMError::Provider { message: formatted_error, metadata: None })
259    }
260
261    /// Attempt a feature-specific fallback when the provider rejects a request.
262    ///
263    /// Returns `Ok(Some(response))` if the fallback succeeds, `Ok(None)` if the
264    /// condition doesn't match (caller should try the next fallback), and `Err`
265    /// for rate-limit or combined error failures.
266    #[allow(
267        clippy::too_many_arguments,
268        reason = "Intentional compatibility, platform, or test-only suppression."
269    )] // fallback dispatcher, all params needed
270    async fn try_feature_fallback(
271        &self,
272        request: &LLMRequest,
273        status: StatusCode,
274        error_text: &str,
275        stream_override: Option<bool>,
276        has_feature: fn(&LLMRequest) -> bool,
277        error_match: &str,
278        warn_message: &str,
279        strip_feature: fn(&LLMRequest) -> LLMRequest,
280        label: &str,
281    ) -> Result<Option<Response>, LLMError> {
282        if has_feature(request) && status == StatusCode::NOT_FOUND && error_text.contains(error_match) {
283            warn!("{}", warn_message);
284            let fallback_request = strip_feature(request);
285            return self
286                .retry_with_fallback(status, error_text, &fallback_request, stream_override, label)
287                .await;
288        }
289        Ok(None)
290    }
291
292    fn build_provider_payload(&self, request: &LLMRequest) -> Result<(Value, String), LLMError> {
293        Ok((self.convert_to_openrouter_format(request)?, chat_completions_url(&self.base_url)))
294    }
295
296    async fn dispatch_request(&self, url: &str, payload: &Value) -> Result<Response, LLMError> {
297        self.dispatch_request_with_session(url, payload, None).await
298    }
299
300    async fn dispatch_request_with_session(
301        &self,
302        url: &str,
303        payload: &Value,
304        session_id: Option<&str>,
305    ) -> Result<Response, LLMError> {
306        let mut req = self
307            .http_client
308            .post(url)
309            .bearer_auth(&self.api_key)
310            .header("HTTP-Referer", OPENROUTER_REFERER)
311            .header("X-OpenRouter-Title", OPENROUTER_TITLE)
312            .header("X-OpenRouter-Categories", OPENROUTER_CATEGORIES);
313        if let Some(session_id) = session_id.filter(|s| !s.trim().is_empty()) {
314            req = req.header("x-session-id", session_id);
315        }
316        req.json(payload)
317            .send()
318            .await
319            .map_err(|e| format_network_error("OpenRouter", &e))
320    }
321
322    async fn send_with_fallback(
323        &self,
324        request: &LLMRequest,
325        stream_override: Option<bool>,
326    ) -> Result<Response, LLMError> {
327        let adjusted_request = self.enforce_tool_capabilities(request);
328        let request_ref = adjusted_request.as_ref();
329
330        let (mut payload, url) = self.build_provider_payload(request_ref)?;
331        if let Some(stream_flag) = stream_override {
332            payload["stream"] = Value::Bool(stream_flag);
333        }
334        let session_id =
335            crate::providers::shared::session_lineage_from_prompt_cache_key(request_ref.prompt_cache_key.as_deref());
336        inject_openrouter_session_identity(&mut payload, session_id.as_deref());
337
338        let response = self
339            .dispatch_request_with_session(&url, &payload, session_id.as_deref())
340            .await?;
341        if response.status().is_success() {
342            return Ok(response);
343        }
344
345        let status = response.status();
346        let error_text = crate::providers::common::read_provider_error_body(response).await;
347
348        if status.as_u16() == 429 || error_text.contains("quota") {
349            return Err(LLMError::RateLimit { metadata: None });
350        }
351
352        if let Some(resp) = self
353            .try_feature_fallback(
354                request_ref,
355                status,
356                &error_text,
357                stream_override,
358                Self::request_includes_tools,
359                "No endpoints found that support tool use",
360                "OpenRouter endpoint does not support tool use; retrying without tools",
361                Self::tool_free_request,
362                "Tool",
363            )
364            .await?
365        {
366            return Ok(resp);
367        }
368
369        if let Some(resp) = self
370            .try_feature_fallback(
371                request_ref,
372                status,
373                &error_text,
374                stream_override,
375                Self::request_includes_images,
376                "No endpoints found that support image input",
377                "OpenRouter endpoint does not support image input; retrying without images",
378                Self::image_free_request,
379                "Image",
380            )
381            .await?
382        {
383            return Ok(resp);
384        }
385
386        // Use unified error parsing for consistent error categorization
387        use crate::providers::error_handling::parse_api_error;
388        Err(parse_api_error("OpenRouter", status, &error_text))
389    }
390}