rig_core/providers/openrouter/
client.rs1use crate::client::{self, BearerAuth, DebugExt, Provider};
2use http::HeaderValue;
3use serde::{Deserialize, Serialize};
4use std::fmt::Debug;
5
6const OPENROUTER_API_BASE_URL: &str = "https://openrouter.ai/api/v1";
10
11#[derive(Debug, Default, Clone, Copy)]
12pub struct OpenRouterExt;
13#[derive(Debug, Default, Clone, Copy)]
14pub struct OpenRouterExtBuilder;
15
16type OpenRouterApiKey = BearerAuth;
17
18pub type Client<H = reqwest::Client> = client::Client<OpenRouterExt, H>;
19pub type ClientBuilder<H = crate::markers::Missing> =
20 client::ClientBuilder<OpenRouterExtBuilder, OpenRouterApiKey, H>;
21
22impl Provider for OpenRouterExt {
23 type Builder = OpenRouterExtBuilder;
24
25 const VERIFY_PATH: &'static str = "/key";
26}
27
28client::impl_capabilities!(
29 OpenRouterExt,
30 completion = super::CompletionModel<H>,
31 embeddings = super::EmbeddingModel<H>,
32 transcription = super::transcription::TranscriptionModel<H>,
33 model_listing = super::OpenRouterModelLister<H>,
34 audio_generation = super::audio_generation::AudioGenerationModel<H>,
35);
36
37impl DebugExt for OpenRouterExt {}
38
39client::impl_default_provider_builder!(
40 OpenRouterExtBuilder => OpenRouterExt,
41 api_key = OpenRouterApiKey,
42 base_url = OPENROUTER_API_BASE_URL,
43);
44
45client::impl_provider_client!(
46 Client,
47 input = OpenRouterApiKey,
48 api_key_env = "OPENROUTER_API_KEY",
49);
50
51#[derive(Clone, Debug, Default, Deserialize, Serialize)]
52pub struct Usage {
53 pub prompt_tokens: usize,
54 #[serde(default)]
55 pub completion_tokens: usize,
56 pub total_tokens: usize,
57 #[serde(default)]
58 pub cost: f64,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub prompt_tokens_details: Option<PromptTokensDetails>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub completion_tokens_details: Option<CompletionTokensDetails>,
69}
70
71#[derive(Clone, Debug, Deserialize, Serialize, Default)]
75pub struct PromptTokensDetails {
76 #[serde(default)]
78 pub cached_tokens: usize,
79 #[serde(default)]
81 pub cache_write_tokens: usize,
82}
83
84#[derive(Clone, Debug, Deserialize, Serialize, Default)]
90pub struct CompletionTokensDetails {
91 #[serde(default)]
94 pub reasoning_tokens: usize,
95}
96
97impl std::fmt::Display for Usage {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 write!(
100 f,
101 "Prompt tokens: {} Total tokens: {}",
102 self.prompt_tokens, self.total_tokens
103 )
104 }
105}
106
107impl From<&Usage> for crate::completion::Usage {
108 fn from(value: &Usage) -> crate::completion::Usage {
109 let (cached_input, cache_creation) = value
110 .prompt_tokens_details
111 .as_ref()
112 .map(|d| (d.cached_tokens as u64, d.cache_write_tokens as u64))
113 .unwrap_or((0, 0));
114 crate::completion::Usage {
115 input_tokens: value.prompt_tokens as u64,
116 output_tokens: if value.completion_tokens > 0 {
120 value.completion_tokens as u64
121 } else {
122 value.total_tokens.saturating_sub(value.prompt_tokens) as u64
123 },
124 total_tokens: value.total_tokens as u64,
125 cached_input_tokens: cached_input,
126 cache_creation_input_tokens: cache_creation,
127 tool_use_prompt_tokens: 0,
128 reasoning_tokens: value
129 .completion_tokens_details
130 .as_ref()
131 .map(|d| d.reasoning_tokens as u64)
132 .unwrap_or(0),
133 }
134 }
135}
136
137impl From<Usage> for crate::completion::Usage {
138 fn from(value: Usage) -> crate::completion::Usage {
139 crate::completion::Usage::from(&value)
140 }
141}
142impl<ApiKey, H> client::ClientBuilder<OpenRouterExtBuilder, ApiKey, H> {
143 pub fn with_app_identity(mut self, title: impl AsRef<str>, url: impl AsRef<str>) -> Self {
148 if let Ok(val) = HeaderValue::from_str(title.as_ref()) {
149 self.headers_mut().insert(
150 http::header::HeaderName::from_static("x-openrouter-title"),
151 val,
152 );
153 }
154 if let Ok(val) = HeaderValue::from_str(url.as_ref()) {
155 self.headers_mut()
156 .insert(http::header::HeaderName::from_static("http-referer"), val);
157 }
158 self
159 }
160
161 pub fn with_app_categories<S>(mut self, categories: &[S]) -> Self
167 where
168 S: AsRef<str>,
169 {
170 let joined = categories
171 .iter()
172 .take(2)
173 .map(|c| c.as_ref())
174 .collect::<Vec<_>>()
175 .join(",");
176 if !joined.is_empty()
177 && let Ok(val) = HeaderValue::from_str(&joined)
178 {
179 self.headers_mut().insert(
180 http::header::HeaderName::from_static("x-openrouter-categories"),
181 val,
182 );
183 }
184 self
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::Usage;
191
192 #[test]
193 fn test_client_initialization() {
194 let _client =
195 crate::providers::openrouter::Client::new("dummy-key").expect("Client::new() failed");
196 let _client_from_builder = crate::providers::openrouter::Client::builder()
197 .api_key("dummy-key")
198 .build()
199 .expect("Client::builder() failed");
200 }
201
202 #[test]
203 fn test_with_app_identity_sets_headers() {
204 let client = crate::providers::openrouter::Client::builder()
205 .with_app_identity("My App", "https://myapp.example.com")
206 .api_key("dummy-key")
207 .build()
208 .expect("Client::builder() failed");
209
210 let headers = client.headers();
211 assert_eq!(
212 headers
213 .get("x-openrouter-title")
214 .and_then(|v| v.to_str().ok()),
215 Some("My App"),
216 );
217 assert_eq!(
218 headers.get("http-referer").and_then(|v| v.to_str().ok()),
219 Some("https://myapp.example.com"),
220 );
221 }
222
223 #[test]
224 fn test_without_app_identity_no_extra_headers() {
225 let client = crate::providers::openrouter::Client::builder()
226 .api_key("dummy-key")
227 .build()
228 .expect("Client::builder() failed");
229
230 let headers = client.headers();
231 assert!(headers.get("x-openrouter-title").is_none());
232 assert!(headers.get("http-referer").is_none());
233 }
234
235 #[test]
236 fn test_with_app_categories_sets_header() {
237 let client = crate::providers::openrouter::Client::builder()
238 .with_app_categories(&["cli-agent", "ide-extension"])
239 .api_key("dummy-key")
240 .build()
241 .expect("Client::builder() failed");
242
243 assert_eq!(
244 client
245 .headers()
246 .get("x-openrouter-categories")
247 .and_then(|v| v.to_str().ok()),
248 Some("cli-agent,ide-extension"),
249 );
250 }
251
252 #[test]
253 fn test_with_app_categories_sends_at_most_two_categories() {
254 let client = crate::providers::openrouter::Client::builder()
255 .with_app_categories(&["cli-agent", "ide-extension", "chat"])
256 .api_key("dummy-key")
257 .build()
258 .expect("Client::builder() failed");
259
260 assert_eq!(
261 client
262 .headers()
263 .get("x-openrouter-categories")
264 .and_then(|v| v.to_str().ok()),
265 Some("cli-agent,ide-extension"),
266 );
267 }
268
269 #[test]
270 fn test_with_app_categories_empty_list_no_header() {
271 let empty: [&str; 0] = [];
272 let client = crate::providers::openrouter::Client::builder()
273 .with_app_categories(&empty)
274 .api_key("dummy-key")
275 .build()
276 .expect("Client::builder() failed");
277
278 assert!(client.headers().get("x-openrouter-categories").is_none());
279 }
280
281 #[test]
282 fn test_without_app_categories_no_header() {
283 let client = crate::providers::openrouter::Client::builder()
284 .api_key("dummy-key")
285 .build()
286 .expect("Client::builder() failed");
287
288 assert!(client.headers().get("x-openrouter-categories").is_none());
289 }
290
291 #[test]
296 fn completion_tokens_details_reaches_normalized_usage() {
297 let usage: Usage = serde_json::from_str(
298 r#"{"completion_tokens":540,
299 "completion_tokens_details":{"audio_tokens":0,"image_tokens":0,"reasoning_tokens":531},
300 "cost":0.002794,
301 "cost_details":{"upstream_inference_completions_cost":0.0027,"upstream_inference_cost":0.002794,"upstream_inference_prompt_cost":0.000094},
302 "is_byok":false,
303 "prompt_tokens":94,
304 "prompt_tokens_details":{"audio_tokens":0,"cache_write_tokens":0,"cached_tokens":0,"video_tokens":0},
305 "total_tokens":634}"#,
306 )
307 .expect("recorded usage should deserialize");
308
309 let normalized = crate::completion::Usage::from(&usage);
310 assert_eq!(normalized.reasoning_tokens, 531);
311 assert_eq!(normalized.output_tokens, 540);
312 assert_eq!(normalized.input_tokens, 94);
313 assert_eq!(normalized.total_tokens, 634);
314 assert!(normalized.reasoning_tokens <= normalized.output_tokens);
316 }
317
318 #[test]
321 fn completion_tokens_details_absent_null_or_zero_all_read_zero() {
322 for body in [
323 r#"{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}"#,
324 r#"{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8,"completion_tokens_details":null}"#,
325 r#"{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8,"completion_tokens_details":{"reasoning_tokens":0}}"#,
326 r#"{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8,"completion_tokens_details":{}}"#,
327 ] {
328 let usage: Usage = serde_json::from_str(body).expect("usage should deserialize");
329 let normalized = crate::completion::Usage::from(&usage);
330 assert_eq!(normalized.reasoning_tokens, 0, "body: {body}");
331 assert_eq!(normalized.output_tokens, 3, "body: {body}");
332 }
333 }
334
335 #[test]
339 fn completion_tokens_details_tolerates_unmodeled_siblings() {
340 let usage: Usage = serde_json::from_str(
341 r#"{"prompt_tokens":9,"completion_tokens":1291,"total_tokens":1300,
342 "completion_tokens_details":{"audio_tokens":0,"image_tokens":1290,"reasoning_tokens":7}}"#,
343 )
344 .expect("usage should deserialize");
345
346 assert_eq!(crate::completion::Usage::from(&usage).reasoning_tokens, 7);
347 }
348
349 #[test]
352 fn completion_tokens_details_does_not_disturb_the_output_token_fallback() {
353 let usage: Usage = serde_json::from_str(
354 r#"{"prompt_tokens":10,"total_tokens":30,
355 "completion_tokens_details":{"reasoning_tokens":12}}"#,
356 )
357 .expect("usage should deserialize");
358
359 let normalized = crate::completion::Usage::from(&usage);
360 assert_eq!(normalized.output_tokens, 20);
361 assert_eq!(normalized.reasoning_tokens, 12);
362 }
363
364 #[test]
367 fn completion_tokens_details_is_omitted_when_absent() {
368 let usage: Usage =
369 serde_json::from_str(r#"{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}"#)
370 .expect("usage should deserialize");
371 let encoded = serde_json::to_string(&usage).expect("usage should serialize");
372
373 assert!(!encoded.contains("completion_tokens_details"), "{encoded}");
374 }
375}