Skip to main content

forge_gateway/
openrouter.rs

1//! The OpenRouter backend: the per-key spend probe against
2//! `{base}/v1/key` with the account credit pool from `{base}/v1/credits`
3//! alongside it. The configured base url already ends in
4//! `/api` (that is what the chat API wants), so only the `/v1/...`
5//! tails are appended; appending the documented site-relative paths
6//! yields `/api/api/...`, which 404s.
7
8use 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
24/// The OpenRouter `[[accounts]] provider` token.
25pub 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
60/// The mapper the account's env earns, paired with the credential that
61/// earns it: the base-url pair earns the spend mapper, and a missing
62/// base url carries its error to the probe's Unmappable surface. Pure
63/// so the routing stays unit-pinned - the probe cannot be driven
64/// against the network offline.
65fn 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    /// The credential bound to the spend mapper: the arm holding this
75    /// cannot map windows.
76    Spend(BaseUrlCredential),
77    MissingBase(MissingBase),
78}
79
80/// OpenRouter's per-key endpoint: `{base}/v1/key`, trailing slash
81/// trimmed so base and base/ behave identically.
82fn key_url(base_url: &str) -> String {
83    format!("{}/v1/key", base_url.trim_end_matches('/'))
84}
85
86/// OpenRouter's account-wide credit endpoint, joined by the same rule
87/// as [`key_url`].
88fn credits_url(base_url: &str) -> String {
89    format!("{}/v1/credits", base_url.trim_end_matches('/'))
90}
91
92/// One probe for a pay-per-token account: the per-key spend from
93/// `{base}/v1/key`, plus the account's credit pool from
94/// `{base}/v1/credits`. Shares [`OauthUsageError`] with the window
95/// probes so the loader and poller classify a 401 / 429 / network
96/// failure the same way regardless of billing kind. The key fetch is
97/// load-bearing and its error propagates; the credits fetch is
98/// auxiliary and its failure only makes the balance absent.
99async 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
109/// The account's remaining credit pool, or `None` when the fetch or
110/// decode fails for any reason, or the 200 carries no `data` envelope -
111/// each logged with its own cause. The balance is auxiliary, and the
112/// `/v1/key` data the panel also renders must stand even when this
113/// endpoint is unavailable.
114async 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
144/// One authenticated GET, classified like the window probes:
145/// 401 / 403 unauthorized, 429 rate-limited (honouring Retry-After),
146/// any other status carrying a truncated body suffix. Returns the
147/// decoded body alongside the raw bytes so a caller can name an
148/// unexpected 200 shape in its own log.
149async 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    // Bodies logged here go through the mask: a 200 from the key
183    // endpoint carries a truncated copy of the key, and a host that
184    // echoes the request carries whatever it was sent.
185    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                // A 200 that will not parse is the shape a wrong base url
210                // takes: the bare host answers 200 with an HTML page. Name
211                // the URL and show the body, or the only evidence is a byte
212                // count on a trace line nobody has enabled. Both fields are
213                // masked: the payload carries a copy of the key, and a serde
214                // message quotes the value it was decoding.
215                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
233/// Map an OpenRouter key payload into a snapshot. Window-free: a
234/// pay-per-token key has no plan window and, when uncapped, no
235/// denominator, so nothing here synthesises a utilization.
236///
237/// `balance` is the account-wide figure from the credits fetch, or
238/// `None` when that fetch failed - it never fails the snapshot.
239///
240/// Fallible on purpose. A 200 whose body carries no `data` envelope, or
241/// an envelope with none of the three usage figures, is a response
242/// forge cannot read rather than a bill of zero - and since `set_usage`
243/// takes any snapshot to `Ready` without inspecting it, mapping those
244/// to zeroes would report a confident number nothing prompts anyone to
245/// doubt. An absent figure alongside a present sibling is a real zero
246/// and maps as one.
247fn 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            // Carried through as-is: a cap that is absent stays absent
275            // rather than becoming a zero, because zero is a cap that
276            // permits nothing and absent is a key with no cap at all.
277            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    /// OpenRouter documents the endpoint as `/api/v1/key` relative to
302    /// the site root, but the configured base url already ends in
303    /// `/api`, so the documented path appended to it 404s. Measured:
304    /// `https://openrouter.ai/api/api/v1/key` is 404 and
305    /// `https://openrouter.ai/api/v1/key` is 401, i.e. the endpoint
306    /// exists and only auth is missing.
307    #[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    /// The arm-routing pin: a base-url credential earns the spend
328    /// mapper carrying that credential; a missing base earns the
329    /// Unmappable arm. Wiring the pair to a window mapper, or probing
330    /// with a credential the env did not produce, cannot compile.
331    #[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    /// A missing base url never reaches the network: the error
347    /// surfaces before a client is built.
348    #[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    /// The production wiring through the real backend and host: a 200
357    /// key body round-trips into the spend snapshot, on the
358    /// Bearer-prefixed env token. Proves the injected client, the
359    /// per-request headers and the status classification, which the
360    /// pure mapper pins cannot see.
361    #[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            // Drain the request before answering: closing with unread
370            // request bytes pending sends an RST that can destroy the
371            // response already in flight.
372            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    /// The verified after-usage shape maps to spend with no windows:
448    /// an API-billed account fabricates neither a window nor a
449    /// percentage, and extra_usage stays unmapped (it is Anthropic
450    /// overage in minor units, not this).
451    #[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    /// A 200 whose body forge cannot read is not a zero bill. Both
490    /// shapes below used to decode to a confident (0.0, 0.0, 0.0) and
491    /// take the account Ready, with the only log line saying success.
492    #[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    /// A cap can be added or removed from the provider's dashboard
512    /// between polls, so both shapes have to map: the capped key
513    /// carries a denominator the panel can draw a bar against, the
514    /// uncapped one carries none and must not be given a synthesised
515    /// zero to stand in for it.
516    #[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    /// One figure present is a real report; its absent siblings are
545    /// genuinely zero, which is what the endpoint means by omitting a
546    /// figure that has a sibling.
547    #[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    /// The live `/v1/credits` shape: the balance is the account's
576    /// remaining pool, credits held minus all usage across every key.
577    #[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    /// A `data` envelope missing either figure must not decode to a
590    /// balance computed from half the inputs; a null envelope has no
591    /// balance to compute at all. Both leave the row absent.
592    #[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    /// Serve sequential responses from `script`, one per connection,
611    /// recording each request's first line so assertions inspect what
612    /// was actually asked for rather than gating the answer on it.
613    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                // Drain the request before answering: closing with unread
627                // request bytes pending sends an RST that can destroy the
628                // response already in flight.
629                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    /// The probe fetches the credit pool alongside the key spend: two
654    /// GETs out of one probe, both Bearer-prefixed on the env token,
655    /// and the balance lands on the snapshot with the spend.
656    #[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    /// The balance is auxiliary: a credits fetch that fails for any
688    /// reason must not fail the probe, because the `/v1/key` data the
689    /// panel also renders still stands and balance only ever renders
690    /// as absent.
691    #[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}