1use std::time::SystemTime;
9
10use async_trait::async_trait;
11use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue};
12use serde::de::DeserializeOwned;
13
14use forge_primitives::usage::oauth::OauthUsageError;
15use forge_primitives::usage::openrouter::{CreditsResponse, KeyResponse};
16use forge_primitives::usage::{ApiSpend, UsageSnapshot, UsageSourceKind};
17
18use crate::helpers::{
19 BaseUrlCredential, MissingBase, OAUTH_TIMEOUT, base_url_credential, parse_retry_after,
20 redacted_body, redacted_message,
21};
22use crate::{AccountEnv, BillingModel, ProbeError, Provider, ProviderBackend, ProviderHost};
23
24pub struct Openrouter;
26
27#[async_trait]
28impl ProviderBackend for Openrouter {
29 fn token(&self) -> Provider {
30 Provider::Openrouter
31 }
32
33 fn billing(&self) -> BillingModel {
34 BillingModel::Spend
35 }
36
37 fn source(&self) -> UsageSourceKind {
38 UsageSourceKind::OpenRouterKey
39 }
40
41 async fn probe(
42 &self,
43 account: &AccountEnv<'_>,
44 host: &dyn ProviderHost,
45 ) -> Result<UsageSnapshot, ProbeError> {
46 match choose_mapper(base_url_credential(account.env)) {
47 Mapper::Spend(credential) => {
48 let client = host.http_client(OAUTH_TIMEOUT).map_err(OauthUsageError::Network)?;
49 let (payload, balance) =
50 key_probe(&client, &credential.base_url, &credential.bearer)
51 .await
52 .map_err(ProbeError::Fetch)?;
53 snapshot_from_openrouter_key(payload, balance)
54 }
55 Mapper::MissingBase(missing) => Err(ProbeError::Unmappable(missing.to_string())),
56 }
57 }
58}
59
60fn choose_mapper(credential: Result<BaseUrlCredential, MissingBase>) -> Mapper {
66 match credential {
67 Ok(credential) => Mapper::Spend(credential),
68 Err(missing) => Mapper::MissingBase(missing),
69 }
70}
71
72#[derive(Debug, PartialEq, Eq)]
73enum Mapper {
74 Spend(BaseUrlCredential),
77 MissingBase(MissingBase),
78}
79
80fn key_url(base_url: &str) -> String {
83 format!("{}/v1/key", base_url.trim_end_matches('/'))
84}
85
86fn credits_url(base_url: &str) -> String {
89 format!("{}/v1/credits", base_url.trim_end_matches('/'))
90}
91
92async fn key_probe(
100 client: &reqwest::Client,
101 base_url: &str,
102 bearer: &str,
103) -> Result<(KeyResponse, Option<f64>), OauthUsageError> {
104 let (key, _) = fetch_json(client, &key_url(base_url), bearer).await?;
105 let balance = credits_balance(client, base_url, bearer).await;
106 Ok((key, balance))
107}
108
109async fn credits_balance(client: &reqwest::Client, base_url: &str, bearer: &str) -> Option<f64> {
115 match fetch_json::<CreditsResponse>(client, &credits_url(base_url), bearer).await {
116 Ok((payload, body)) => {
117 let balance = balance_from_credits(payload);
118 if balance.is_none() {
119 tracing::warn!(
120 target: "forge_gateway::openrouter",
121 event_name = "openrouter_credits_no_envelope",
122 body_suffix = %redacted_body(&body, bearer),
123 "200 from the credits endpoint carried no data envelope; balance stays absent",
124 );
125 }
126 balance
127 }
128 Err(error) => {
129 tracing::warn!(
130 target: "forge_gateway::openrouter",
131 event_name = "openrouter_credits_skipped",
132 error = %error,
133 "balance stays absent; the credits fetch failed but the key data stands",
134 );
135 None
136 }
137 }
138}
139
140fn balance_from_credits(payload: CreditsResponse) -> Option<f64> {
141 payload.data.map(|data| data.total_credits - data.total_usage)
142}
143
144async fn fetch_json<T: DeserializeOwned>(
150 client: &reqwest::Client,
151 url: &str,
152 bearer: &str,
153) -> Result<(T, Vec<u8>), OauthUsageError> {
154 let mut headers = HeaderMap::new();
155 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
156 let auth = HeaderValue::from_str(&format!("Bearer {bearer}"))
157 .map_err(|error| OauthUsageError::Network(format!("bad bearer header: {error}")))?;
158 headers.insert(AUTHORIZATION, auth);
159
160 let response = client
161 .get(url)
162 .headers(headers)
163 .send()
164 .await
165 .map_err(|error| OauthUsageError::Network(error.to_string()))?;
166
167 let status = response.status().as_u16();
168 let retry_after = if status == 429 {
169 response
170 .headers()
171 .get(reqwest::header::RETRY_AFTER)
172 .and_then(|v| v.to_str().ok())
173 .and_then(parse_retry_after)
174 } else {
175 None
176 };
177 let body = response
178 .bytes()
179 .await
180 .map_err(|error| OauthUsageError::Network(format!("body read: {error}")))?;
181
182 if status == 200 {
186 tracing::trace!(
187 target: "forge_gateway::openrouter",
188 event_name = "openrouter_get_response",
189 url,
190 status,
191 outcome = "ok",
192 body_bytes = body.len(),
193 );
194 } else {
195 tracing::warn!(
196 target: "forge_gateway::openrouter",
197 event_name = "openrouter_get_response",
198 url,
199 status,
200 outcome = "non_ok",
201 retry_after_secs = ?retry_after.map(|d| d.as_secs()),
202 body_suffix = %redacted_body(&body, bearer),
203 );
204 }
205
206 match status {
207 200 => {
208 serde_json::from_slice(&body).map(|decoded| (decoded, body.to_vec())).map_err(|error| {
209 let said = redacted_message(&error.to_string(), bearer);
216 tracing::warn!(
217 target: "forge_gateway::openrouter",
218 event_name = "openrouter_get_decode_failed",
219 url,
220 error = %said,
221 body_suffix = %redacted_body(&body, bearer),
222 "200 did not decode; check the base url is the API root",
223 );
224 OauthUsageError::Decode(said)
225 })
226 }
227 401 | 403 => Err(OauthUsageError::Unauthorized(status)),
228 429 => Err(OauthUsageError::RateLimited { retry_after, snapshot: None }),
229 _ => Err(OauthUsageError::HttpStatus(status, redacted_body(&body, bearer))),
230 }
231}
232
233fn snapshot_from_openrouter_key(
248 payload: KeyResponse,
249 balance: Option<f64>,
250) -> Result<UsageSnapshot, ProbeError> {
251 let Some(data) = payload.data else {
252 return Err(ProbeError::Unmappable(
253 "OpenRouter key response carried no data envelope.".to_owned(),
254 ));
255 };
256 if data.usage_daily.is_none() && data.usage_weekly.is_none() && data.usage_monthly.is_none() {
257 return Err(ProbeError::Unmappable(
258 "OpenRouter key response carried no usage figures.".to_owned(),
259 ));
260 }
261 Ok(UsageSnapshot {
262 source: UsageSourceKind::OpenRouterKey,
263 fetched_at: SystemTime::now(),
264 five_hour: None,
265 seven_day: None,
266 seven_day_opus: None,
267 seven_day_sonnet: None,
268 extra_usage: None,
269 balance,
270 spend: Some(ApiSpend {
271 daily: data.usage_daily.unwrap_or(0.0),
272 weekly: data.usage_weekly.unwrap_or(0.0),
273 monthly: data.usage_monthly.unwrap_or(0.0),
274 limit: data.limit,
278 limit_remaining: data.limit_remaining,
279 limit_reset: data.limit_reset,
280 expires_at: data.expires_at,
281 }),
282 })
283}
284
285#[cfg(test)]
286mod tests {
287 use std::collections::HashMap;
288 use std::time::Duration;
289
290 use async_trait::async_trait;
291
292 use super::*;
293
294 fn env_with_base(base: &str) -> HashMap<String, String> {
295 let mut env = HashMap::new();
296 env.insert("ANTHROPIC_BASE_URL".to_owned(), base.to_owned());
297 env.insert("ANTHROPIC_AUTH_TOKEN".to_owned(), "sk-or-test".to_owned());
298 env
299 }
300
301 #[test]
308 fn key_url_joins_one_v1_segment_onto_the_configured_base() {
309 assert_eq!(key_url("https://openrouter.ai/api"), "https://openrouter.ai/api/v1/key");
310 assert_eq!(
311 key_url("https://openrouter.ai/api/"),
312 "https://openrouter.ai/api/v1/key",
313 "trailing slash trimmed so base and base/ behave identically",
314 );
315 assert!(
316 !key_url("https://openrouter.ai/api").contains("/api/api/"),
317 "the documented /api/v1/key path must not be appended to a base that ends in /api",
318 );
319 }
320
321 #[test]
322 fn openrouter_backend_is_spend() {
323 assert_eq!(Openrouter.token(), Provider::Openrouter);
324 assert_eq!(Openrouter.billing(), BillingModel::Spend);
325 }
326
327 #[test]
332 fn a_base_url_credential_earns_the_spend_mapper_and_a_missing_base_the_error_arm() {
333 let credential =
334 base_url_credential(&env_with_base("https://openrouter.ai/api")).expect("credential");
335 assert_eq!(
336 choose_mapper(Ok(credential.clone())),
337 Mapper::Spend(credential),
338 "the spend arm runs with the credential that earned it",
339 );
340 assert!(
341 matches!(choose_mapper(Err(MissingBase)), Mapper::MissingBase(_)),
342 "a missing base surfaces Unmappable, not a probe against a default host",
343 );
344 }
345
346 #[tokio::test]
349 async fn a_missing_base_is_unmappable_without_probing() {
350 let backend = Openrouter;
351 let account = AccountEnv { env: &HashMap::new() };
352 let result = backend.probe(&account, &UnreachableHost).await;
353 assert!(matches!(result, Err(ProbeError::Unmappable(_))), "got {result:?}");
354 }
355
356 #[tokio::test]
362 async fn a_200_key_body_probes_through_to_a_spend_snapshot() {
363 let body = br#"{"data":{"usage_daily":0.25}}"#;
364 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
365 let addr = listener.local_addr().expect("addr");
366 std::thread::spawn(move || {
367 use std::io::{Read, Write as _};
368 let Ok((mut sock, _)) = listener.accept() else { return };
369 let mut request = Vec::new();
373 let mut byte = [0u8; 1];
374 while !request.ends_with(b"\r\n\r\n") {
375 match sock.read(&mut byte) {
376 Ok(1) => request.push(byte[0]),
377 _ => break,
378 }
379 }
380 let request_text = String::from_utf8_lossy(&request).into_owned();
381 assert!(
382 request_text.contains("GET /v1/key HTTP/1.1"),
383 "the request must hit /v1/key, got: {}",
384 request_text.lines().next().unwrap_or_default()
385 );
386 assert!(
387 request_text.contains("authorization: Bearer sk-or-test"),
388 "the env token goes out Bearer-prefixed, got: {request_text}",
389 );
390 let header = format!(
391 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
392 body.len()
393 );
394 let _ = sock.write_all(header.as_bytes());
395 let _ = sock.write_all(body);
396 let _ = sock.shutdown(std::net::Shutdown::Both);
397 });
398 let env = env_with_base(&format!("http://{addr}"));
399 let account = AccountEnv { env: &env };
400 let snapshot = Openrouter.probe(&account, &LocalHost).await.expect("snapshot");
401 let spend = snapshot.spend.expect("spend");
402 assert!((spend.daily - 0.25).abs() < f64::EPSILON, "got {spend:?}");
403 }
404
405 struct LocalHost;
406
407 #[async_trait]
408 impl ProviderHost for LocalHost {
409 fn http_client(&self, timeout: Duration) -> Result<reqwest::Client, String> {
410 reqwest::Client::builder().timeout(timeout).build().map_err(|e| e.to_string())
411 }
412
413 fn streaming_http_client(
414 &self,
415 _connect_timeout: Duration,
416 _idle_timeout: Duration,
417 ) -> Result<reqwest::Client, String> {
418 unreachable!("no probe streams a response")
419 }
420
421 async fn user_agent(&self) -> Result<String, String> {
422 unreachable!("the openrouter probe sends no User-Agent header")
423 }
424 }
425
426 struct UnreachableHost;
427
428 #[async_trait]
429 impl ProviderHost for UnreachableHost {
430 fn http_client(&self, _timeout: Duration) -> Result<reqwest::Client, String> {
431 unreachable!("the probe must not build a client for a missing base url")
432 }
433
434 fn streaming_http_client(
435 &self,
436 _connect_timeout: Duration,
437 _idle_timeout: Duration,
438 ) -> Result<reqwest::Client, String> {
439 unreachable!("the probe must not build a client for a missing base url")
440 }
441
442 async fn user_agent(&self) -> Result<String, String> {
443 unreachable!("the probe must not resolve a UA for a missing base url")
444 }
445 }
446
447 #[test]
452 fn openrouter_key_maps_to_spend_with_no_windows() {
453 let payload: KeyResponse = serde_json::from_str(
454 r#"{"data":{
455 "label":"sk-or-v1-TEST...TEST",
456 "limit":null,"limit_reset":null,"limit_remaining":null,
457 "usage":198.552152461,
458 "usage_daily":0.5632267,
459 "usage_weekly":1.25,
460 "usage_monthly":20.296155711,
461 "byok_usage":0.000365,
462 "is_free_tier":false
463 }}"#,
464 )
465 .expect("decode");
466 let snapshot = snapshot_from_openrouter_key(payload, None).expect("a real payload maps");
467
468 assert_eq!(snapshot.source, UsageSourceKind::OpenRouterKey);
469 let spend = snapshot.spend.expect("spend is populated");
470 assert!(
471 (spend.daily - 0.563_226_7).abs() < f64::EPSILON,
472 "daily spend comes straight off usage_daily",
473 );
474 assert!((spend.weekly - 1.25).abs() < f64::EPSILON, "weekly spend maps");
475 assert!((spend.monthly - 20.296_155_711).abs() < f64::EPSILON, "monthly spend maps");
476 assert!(
477 snapshot.five_hour.is_none()
478 && snapshot.seven_day.is_none()
479 && snapshot.seven_day_opus.is_none()
480 && snapshot.seven_day_sonnet.is_none(),
481 "an API-billed account has no plan window to fabricate",
482 );
483 assert!(
484 snapshot.extra_usage.is_none(),
485 "extra_usage is Anthropic overage in minor units, not this",
486 );
487 }
488
489 #[test]
493 fn an_unreadable_openrouter_body_is_an_error_not_zero_spend() {
494 let no_envelope: KeyResponse =
495 serde_json::from_str(r#"{"error":{"message":"User not found.","code":401}}"#)
496 .expect("decodes structurally");
497 assert!(
498 snapshot_from_openrouter_key(no_envelope, None).is_err(),
499 "a body with no data envelope carries no spend and must not read as zero",
500 );
501
502 let no_figures: KeyResponse =
503 serde_json::from_str(r#"{"data":{"label":"sk-or-v1-TEST","is_free_tier":false}}"#)
504 .expect("decodes structurally");
505 assert!(
506 snapshot_from_openrouter_key(no_figures, None).is_err(),
507 "an envelope with none of the three usage figures must not read as zero",
508 );
509 }
510
511 #[test]
517 fn a_cap_maps_when_present_and_stays_absent_when_not() {
518 let capped: KeyResponse = serde_json::from_str(
519 r#"{"data":{"usage_daily":0.038869563,"usage_weekly":0.038869563,
520 "usage_monthly":0.038869563,"limit":20,
521 "limit_remaining":19.961130437,"limit_reset":"monthly",
522 "expires_at":null}}"#,
523 )
524 .expect("decode");
525 let spend = snapshot_from_openrouter_key(capped, None).expect("maps").spend.expect("spend");
526 assert_eq!(spend.limit, Some(20.0), "the cap is the denominator a bar needs");
527 assert_eq!(spend.limit_remaining, Some(19.961_130_437), "what is left to spend");
528 assert_eq!(spend.limit_reset.as_deref(), Some("monthly"), "the window the cap resets on");
529 assert_eq!(spend.expires_at, None, "a key with no expiry reports none");
530
531 let uncapped: KeyResponse = serde_json::from_str(
532 r#"{"data":{"usage_daily":0.56,"usage_weekly":1.25,"usage_monthly":20.30,
533 "limit":null,"limit_remaining":null,"limit_reset":null}}"#,
534 )
535 .expect("decode");
536 let spend =
537 snapshot_from_openrouter_key(uncapped, None).expect("maps").spend.expect("spend");
538 assert_eq!(spend.limit, None, "an uncapped key has no denominator to invent");
539 assert_eq!(spend.limit_remaining, None);
540 assert_eq!(spend.limit_reset, None);
541 assert!((spend.monthly - 20.30).abs() < f64::EPSILON, "spend still maps without a cap");
542 }
543
544 #[test]
548 fn a_partial_openrouter_body_maps_its_present_figure() {
549 let partial: KeyResponse =
550 serde_json::from_str(r#"{"data":{"usage_daily":0.25}}"#).expect("decode");
551 let spend = snapshot_from_openrouter_key(partial, None)
552 .expect("one present figure is a readable report")
553 .spend
554 .expect("spend");
555 assert!((spend.daily - 0.25).abs() < f64::EPSILON, "the present figure maps");
556 assert!(
557 spend.weekly.abs() < f64::EPSILON && spend.monthly.abs() < f64::EPSILON,
558 "absent siblings of a present figure are zero",
559 );
560 }
561
562 #[test]
563 fn credits_url_joins_one_v1_segment_onto_the_configured_base() {
564 assert_eq!(
565 credits_url("https://openrouter.ai/api"),
566 "https://openrouter.ai/api/v1/credits",
567 );
568 assert_eq!(
569 credits_url("https://openrouter.ai/api/"),
570 "https://openrouter.ai/api/v1/credits",
571 "trailing slash trimmed so base and base/ behave identically",
572 );
573 }
574
575 #[test]
578 fn the_credits_payload_maps_to_the_remaining_pool() {
579 let payload: CreditsResponse =
580 serde_json::from_str(r#"{"data":{"total_credits":435.0,"total_usage":370.60}}"#)
581 .expect("decode");
582 let balance = balance_from_credits(payload).expect("balance");
583 assert!(
584 (balance - 64.40).abs() < 0.001,
585 "435.0 - 370.60 is the pool left to spend, got {balance}",
586 );
587 }
588
589 #[test]
593 fn an_incomplete_credits_payload_yields_no_balance() {
594 let missing_usage: Result<CreditsResponse, _> =
595 serde_json::from_str(r#"{"data":{"total_credits":435.0}}"#);
596 assert!(
597 missing_usage.is_err(),
598 "a balance needs both figures; half of one is not a reading",
599 );
600
601 let null_envelope: CreditsResponse =
602 serde_json::from_str(r#"{"data":null}"#).expect("decodes structurally");
603 assert_eq!(
604 balance_from_credits(null_envelope),
605 None,
606 "no data envelope means no balance, not a zero pool",
607 );
608 }
609
610 fn spawn_scripted_server(
614 script: Vec<(u16, String)>,
615 ) -> (std::net::SocketAddr, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
616 use std::io::{Read, Write as _};
617 use std::sync::{Arc, Mutex};
618
619 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
620 let addr = listener.local_addr().expect("addr");
621 let seen = Arc::new(Mutex::new(Vec::new()));
622 let seen_clone = Arc::clone(&seen);
623 std::thread::spawn(move || {
624 for (status, body) in script {
625 let Ok((mut sock, _)) = listener.accept() else { return };
626 let mut request = Vec::new();
630 let mut byte = [0u8; 1];
631 while !request.ends_with(b"\r\n\r\n") {
632 match sock.read(&mut byte) {
633 Ok(1) => request.push(byte[0]),
634 _ => break,
635 }
636 }
637 seen_clone
638 .lock()
639 .expect("request log")
640 .push(String::from_utf8_lossy(&request).into_owned());
641 let header = format!(
642 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
643 body.len()
644 );
645 let _ = sock.write_all(header.as_bytes());
646 let _ = sock.write_all(body.as_bytes());
647 let _ = sock.shutdown(std::net::Shutdown::Both);
648 }
649 });
650 (addr, seen)
651 }
652
653 #[tokio::test]
657 async fn the_probe_fetches_the_credit_pool_alongside_the_key_spend() {
658 let (addr, seen) = spawn_scripted_server(vec![
659 (200, r#"{"data":{"usage_daily":0.25}}"#.to_owned()),
660 (200, r#"{"data":{"total_credits":435.0,"total_usage":370.60}}"#.to_owned()),
661 ]);
662 let env = env_with_base(&format!("http://{addr}"));
663 let account = AccountEnv { env: &env };
664 let snapshot = Openrouter.probe(&account, &LocalHost).await.expect("snapshot");
665
666 let spend = snapshot.spend.expect("spend");
667 assert!((spend.daily - 0.25).abs() < f64::EPSILON, "got {spend:?}");
668 let balance = snapshot.balance.expect("the credits fetch lands a balance");
669 assert!((balance - 64.40).abs() < 0.001, "got {balance}");
670
671 let requests = seen.lock().expect("request log");
672 assert_eq!(requests.len(), 2, "one probe, two GETs: {requests:?}");
673 assert!(
674 requests[0].contains("GET /v1/key HTTP/1.1"),
675 "the key fetch is unchanged: {requests:?}",
676 );
677 assert!(
678 requests[1].contains("GET /v1/credits HTTP/1.1"),
679 "the credits fetch hits /v1/credits: {requests:?}",
680 );
681 assert!(
682 requests.iter().all(|r| r.contains("authorization: Bearer sk-or-test")),
683 "both GETs ride the same Bearer token: {requests:?}",
684 );
685 }
686
687 #[tokio::test]
692 async fn a_credits_failure_leaves_the_key_snapshot_standing() {
693 let (addr, seen) = spawn_scripted_server(vec![
694 (200, r#"{"data":{"usage_daily":0.25}}"#.to_owned()),
695 (401, r#"{"error":{"message":"No auth credentials","code":401}}"#.to_owned()),
696 ]);
697 let env = env_with_base(&format!("http://{addr}"));
698 let account = AccountEnv { env: &env };
699 let snapshot = Openrouter.probe(&account, &LocalHost).await.expect("snapshot");
700
701 assert!(snapshot.spend.is_some(), "the key data stands when the credits fetch fails");
702 assert_eq!(snapshot.balance, None, "a failed credits fetch is an absent balance");
703 assert_eq!(seen.lock().expect("request log").len(), 2);
704 }
705}