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
27fn 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 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 #[allow(
304 clippy::too_many_arguments,
305 reason = "Intentional compatibility, platform, or test-only suppression."
306 )] 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 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 crate::providers::error_handling::parse_api_error;
428 Err(parse_api_error("OpenRouter", status, &error_text))
429 }
430}