Skip to main content

rig_core/providers/openrouter/
client.rs

1use crate::client::{self, BearerAuth, DebugExt, Provider};
2use http::HeaderValue;
3use serde::{Deserialize, Serialize};
4use std::fmt::Debug;
5
6// ================================================================
7// Main openrouter Client
8// ================================================================
9const 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    /// OpenAI-compatible prompt-token details, returned by OpenRouter when a
60    /// provider reports cache activity (Anthropic with cache_control, OpenAI
61    /// with server-side automatic caching).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub prompt_tokens_details: Option<PromptTokensDetails>,
64    /// OpenAI-compatible completion-token breakdown. OpenRouter includes full
65    /// usage accounting on every response, so a reasoning-capable route
66    /// reports here how much of `completion_tokens` went to hidden reasoning.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub completion_tokens_details: Option<CompletionTokensDetails>,
69}
70
71/// Prompt-token breakdown reported by OpenRouter for cached requests.
72// `usize` matches the parent `Usage` struct in this module; the streaming counterpart
73// in `streaming.rs` uses `u32` to match its own parent.
74#[derive(Clone, Debug, Deserialize, Serialize, Default)]
75pub struct PromptTokensDetails {
76    /// Tokens served from cache (cache hit).
77    #[serde(default)]
78    pub cached_tokens: usize,
79    /// Tokens written to cache on this call (cache miss that populated the cache).
80    #[serde(default)]
81    pub cache_write_tokens: usize,
82}
83
84/// Completion-token breakdown reported by OpenRouter.
85///
86/// Only the reasoning share is modeled: it is the one entry rig's normalized
87/// [`crate::completion::Usage`] has a slot for, and OpenRouter documents usage
88/// accounting as always present (on the final SSE message when streaming).
89#[derive(Clone, Debug, Deserialize, Serialize, Default)]
90pub struct CompletionTokensDetails {
91    /// Tokens the upstream spent on hidden reasoning, counted inside
92    /// `completion_tokens`.
93    #[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            // Reported completion tokens, falling back to saturating
117            // total - prompt for gateways that omit the field (it
118            // deserializes to 0).
119            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    /// Attach OpenRouter app-identification headers (`X-OpenRouter-Title` and `HTTP-Referer`)
144    /// to every request made by this client. `title` appears in the dashboard activity feed
145    /// and rankings page; `url` is the primary app identifier required to create an app page
146    /// on OpenRouter. Invalid (non-ASCII) values are silently skipped.
147    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    /// Assign this app to up to two OpenRouter marketplace categories via the
162    /// `X-OpenRouter-Categories` header. Categories must be lowercase and hyphen-separated
163    /// (e.g. `"cli-agent"`, `"ide-extension"`). OpenRouter silently ignores unrecognized
164    /// categories. Extra categories beyond the first two are not sent. Invalid (non-ASCII)
165    /// values are silently skipped.
166    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    /// A real usage object, copied verbatim out of
292    /// `tests/cassettes/openrouter/reasoning_usage_matrix/blocking_anthropic_routed_reports_reasoning_tokens.yaml`:
293    /// the breakdown must survive deserialization and reach the normalized
294    /// `reasoning_tokens` slot, unmodeled siblings and all.
295    #[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        // The reasoning share is counted *inside* the completion tokens.
315        assert!(normalized.reasoning_tokens <= normalized.output_tokens);
316    }
317
318    /// A non-reasoning route reports the object with a zero share; a gateway
319    /// that omits it entirely, or sends it as `null`, must read the same.
320    #[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    /// Unknown siblings inside the breakdown (OpenRouter sends `audio_tokens`
336    /// and `image_tokens`) must not fail the decode — the object is read for
337    /// the one entry rig has a slot for.
338    #[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    /// The completion-token fallback (`total - prompt` for gateways that omit
350    /// `completion_tokens`) must stay independent of the new field.
351    #[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    /// Round-tripping the type must not start sending a breakdown rig never
365    /// received: the field is skipped when absent.
366    #[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}