Skip to main content

rig_core/providers/openrouter/
client.rs

1use crate::{
2    client::{
3        self, BearerAuth, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
4        ProviderClient,
5    },
6    completion::GetTokenUsage,
7    http_client,
8};
9use http::HeaderValue;
10use serde::{Deserialize, Serialize};
11use std::fmt::Debug;
12
13// ================================================================
14// Main openrouter Client
15// ================================================================
16const OPENROUTER_API_BASE_URL: &str = "https://openrouter.ai/api/v1";
17
18#[derive(Debug, Default, Clone, Copy)]
19pub struct OpenRouterExt;
20#[derive(Debug, Default, Clone, Copy)]
21pub struct OpenRouterExtBuilder;
22
23type OpenRouterApiKey = BearerAuth;
24
25pub type Client<H = reqwest::Client> = client::Client<OpenRouterExt, H>;
26pub type ClientBuilder<H = crate::markers::Missing> =
27    client::ClientBuilder<OpenRouterExtBuilder, OpenRouterApiKey, H>;
28
29impl Provider for OpenRouterExt {
30    type Builder = OpenRouterExtBuilder;
31
32    const VERIFY_PATH: &'static str = "/key";
33}
34
35impl<H> Capabilities<H> for OpenRouterExt {
36    type Completion = Capable<super::CompletionModel<H>>;
37    type Embeddings = Capable<super::EmbeddingModel<H>>;
38    type Transcription = Capable<super::transcription::TranscriptionModel<H>>;
39    type ModelListing = Capable<super::OpenRouterModelLister<H>>;
40    #[cfg(feature = "image")]
41    type ImageGeneration = Nothing;
42
43    #[cfg(feature = "audio")]
44    type AudioGeneration = Capable<super::audio_generation::AudioGenerationModel<H>>;
45    type Rerank = Nothing;
46}
47
48impl DebugExt for OpenRouterExt {}
49
50impl ProviderBuilder for OpenRouterExtBuilder {
51    type Extension<H>
52        = OpenRouterExt
53    where
54        H: http_client::HttpClientExt;
55    type ApiKey = OpenRouterApiKey;
56
57    const BASE_URL: &'static str = OPENROUTER_API_BASE_URL;
58
59    fn build<H>(
60        _builder: &crate::client::ClientBuilder<Self, Self::ApiKey, H>,
61    ) -> http_client::Result<Self::Extension<H>>
62    where
63        H: http_client::HttpClientExt,
64    {
65        Ok(OpenRouterExt)
66    }
67}
68
69impl ProviderClient for Client {
70    type Input = OpenRouterApiKey;
71    type Error = crate::client::ProviderClientError;
72
73    /// Create a new openrouter client from the `OPENROUTER_API_KEY` environment variable.
74    fn from_env() -> Result<Self, Self::Error> {
75        let api_key = crate::client::required_env_var("OPENROUTER_API_KEY")?;
76
77        Self::new(&api_key).map_err(Into::into)
78    }
79
80    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
81        Self::new(input).map_err(Into::into)
82    }
83}
84
85#[derive(Debug, Deserialize)]
86pub(crate) struct ApiErrorResponse {
87    pub message: String,
88}
89
90#[derive(Debug, Deserialize)]
91#[serde(untagged)]
92pub(crate) enum ApiResponse<T> {
93    Ok(T),
94    Err(ApiErrorResponse),
95}
96
97#[derive(Clone, Debug, Deserialize, Serialize)]
98pub struct Usage {
99    pub prompt_tokens: usize,
100    #[serde(default)]
101    pub completion_tokens: usize,
102    pub total_tokens: usize,
103    #[serde(default)]
104    pub cost: f64,
105    /// OpenAI-compatible prompt-token details, returned by OpenRouter when a
106    /// provider reports cache activity (Anthropic with cache_control, OpenAI
107    /// with server-side automatic caching).
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub prompt_tokens_details: Option<PromptTokensDetails>,
110}
111
112/// Prompt-token breakdown reported by OpenRouter for cached requests.
113// `usize` matches the parent `Usage` struct in this module; the streaming counterpart
114// in `streaming.rs` uses `u32` to match its own parent.
115#[derive(Clone, Debug, Deserialize, Serialize, Default)]
116pub struct PromptTokensDetails {
117    /// Tokens served from cache (cache hit).
118    #[serde(default)]
119    pub cached_tokens: usize,
120    /// Tokens written to cache on this call (cache miss that populated the cache).
121    #[serde(default)]
122    pub cache_write_tokens: usize,
123}
124
125impl std::fmt::Display for Usage {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(
128            f,
129            "Prompt tokens: {} Total tokens: {}",
130            self.prompt_tokens, self.total_tokens
131        )
132    }
133}
134
135impl GetTokenUsage for Usage {
136    fn token_usage(&self) -> crate::completion::Usage {
137        let (cached_input, cache_creation) = self
138            .prompt_tokens_details
139            .as_ref()
140            .map(|d| (d.cached_tokens as u64, d.cache_write_tokens as u64))
141            .unwrap_or((0, 0));
142        crate::completion::Usage {
143            input_tokens: self.prompt_tokens as u64,
144            output_tokens: self.completion_tokens as u64,
145            total_tokens: self.total_tokens as u64,
146            cached_input_tokens: cached_input,
147            cache_creation_input_tokens: cache_creation,
148            tool_use_prompt_tokens: 0,
149            reasoning_tokens: 0,
150        }
151    }
152}
153impl<ApiKey, H> client::ClientBuilder<OpenRouterExtBuilder, ApiKey, H> {
154    /// Attach OpenRouter app-identification headers (`X-OpenRouter-Title` and `HTTP-Referer`)
155    /// to every request made by this client. `title` appears in the dashboard activity feed
156    /// and rankings page; `url` is the primary app identifier required to create an app page
157    /// on OpenRouter. Invalid (non-ASCII) values are silently skipped.
158    pub fn with_app_identity(mut self, title: impl AsRef<str>, url: impl AsRef<str>) -> Self {
159        if let Ok(val) = HeaderValue::from_str(title.as_ref()) {
160            self.headers_mut().insert(
161                http::header::HeaderName::from_static("x-openrouter-title"),
162                val,
163            );
164        }
165        if let Ok(val) = HeaderValue::from_str(url.as_ref()) {
166            self.headers_mut()
167                .insert(http::header::HeaderName::from_static("http-referer"), val);
168        }
169        self
170    }
171
172    /// Assign this app to up to two OpenRouter marketplace categories via the
173    /// `X-OpenRouter-Categories` header. Categories must be lowercase and hyphen-separated
174    /// (e.g. `"cli-agent"`, `"ide-extension"`). OpenRouter silently ignores unrecognized
175    /// categories. Extra categories beyond the first two are not sent. Invalid (non-ASCII)
176    /// values are silently skipped.
177    pub fn with_app_categories<S>(mut self, categories: &[S]) -> Self
178    where
179        S: AsRef<str>,
180    {
181        let joined = categories
182            .iter()
183            .take(2)
184            .map(|c| c.as_ref())
185            .collect::<Vec<_>>()
186            .join(",");
187        if !joined.is_empty()
188            && let Ok(val) = HeaderValue::from_str(&joined)
189        {
190            self.headers_mut().insert(
191                http::header::HeaderName::from_static("x-openrouter-categories"),
192                val,
193            );
194        }
195        self
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    #[test]
202    fn test_client_initialization() {
203        let _client =
204            crate::providers::openrouter::Client::new("dummy-key").expect("Client::new() failed");
205        let _client_from_builder = crate::providers::openrouter::Client::builder()
206            .api_key("dummy-key")
207            .build()
208            .expect("Client::builder() failed");
209    }
210
211    #[test]
212    fn test_with_app_identity_sets_headers() {
213        let client = crate::providers::openrouter::Client::builder()
214            .with_app_identity("My App", "https://myapp.example.com")
215            .api_key("dummy-key")
216            .build()
217            .expect("Client::builder() failed");
218
219        let headers = client.headers();
220        assert_eq!(
221            headers
222                .get("x-openrouter-title")
223                .and_then(|v| v.to_str().ok()),
224            Some("My App"),
225        );
226        assert_eq!(
227            headers.get("http-referer").and_then(|v| v.to_str().ok()),
228            Some("https://myapp.example.com"),
229        );
230    }
231
232    #[test]
233    fn test_without_app_identity_no_extra_headers() {
234        let client = crate::providers::openrouter::Client::builder()
235            .api_key("dummy-key")
236            .build()
237            .expect("Client::builder() failed");
238
239        let headers = client.headers();
240        assert!(headers.get("x-openrouter-title").is_none());
241        assert!(headers.get("http-referer").is_none());
242    }
243
244    #[test]
245    fn test_with_app_categories_sets_header() {
246        let client = crate::providers::openrouter::Client::builder()
247            .with_app_categories(&["cli-agent", "ide-extension"])
248            .api_key("dummy-key")
249            .build()
250            .expect("Client::builder() failed");
251
252        assert_eq!(
253            client
254                .headers()
255                .get("x-openrouter-categories")
256                .and_then(|v| v.to_str().ok()),
257            Some("cli-agent,ide-extension"),
258        );
259    }
260
261    #[test]
262    fn test_with_app_categories_sends_at_most_two_categories() {
263        let client = crate::providers::openrouter::Client::builder()
264            .with_app_categories(&["cli-agent", "ide-extension", "chat"])
265            .api_key("dummy-key")
266            .build()
267            .expect("Client::builder() failed");
268
269        assert_eq!(
270            client
271                .headers()
272                .get("x-openrouter-categories")
273                .and_then(|v| v.to_str().ok()),
274            Some("cli-agent,ide-extension"),
275        );
276    }
277
278    #[test]
279    fn test_with_app_categories_empty_list_no_header() {
280        let empty: [&str; 0] = [];
281        let client = crate::providers::openrouter::Client::builder()
282            .with_app_categories(&empty)
283            .api_key("dummy-key")
284            .build()
285            .expect("Client::builder() failed");
286
287        assert!(client.headers().get("x-openrouter-categories").is_none());
288    }
289
290    #[test]
291    fn test_without_app_categories_no_header() {
292        let client = crate::providers::openrouter::Client::builder()
293            .api_key("dummy-key")
294            .build()
295            .expect("Client::builder() failed");
296
297        assert!(client.headers().get("x-openrouter-categories").is_none());
298    }
299}