rig_core/providers/openrouter/
client.rs1use 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
13const 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 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 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub prompt_tokens_details: Option<PromptTokensDetails>,
110}
111
112#[derive(Clone, Debug, Deserialize, Serialize, Default)]
116pub struct PromptTokensDetails {
117 #[serde(default)]
119 pub cached_tokens: usize,
120 #[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 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 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}