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
26fn 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 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 #[allow(
267 clippy::too_many_arguments,
268 reason = "Intentional compatibility, platform, or test-only suppression."
269 )] 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 crate::providers::error_handling::parse_api_error;
388 Err(parse_api_error("OpenRouter", status, &error_text))
389 }
390}