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