Skip to main content

forge_gateway/
helpers.rs

1//! Composable pieces the backends build probes and snapshots from:
2//! the windowed `/api/oauth/usage` round-trip, the payload-to-window
3//! mappers, and the timestamp parsing the snapshot mappers share.
4
5use std::collections::HashMap;
6use std::time::{Duration, SystemTime};
7
8use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT};
9use serde_json::Value;
10
11use forge_primitives::usage::oauth::{
12    OauthExtraUsage, OauthUsage, OauthUsageError, OauthUsageWindow,
13};
14use forge_primitives::usage::{ExtraUsage, UsageSnapshot, UsageSourceKind, UsageWindow};
15
16/// Per-call timeout the probes bake into their client.
17pub const OAUTH_TIMEOUT: Duration = Duration::from_secs(8);
18
19const OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
20const OAUTH_BETA_HEADER: &str = "oauth-2025-04-20";
21
22/// The base-url credential an account's `env` carries: the
23/// `ANTHROPIC_BASE_URL` endpoint and the `ANTHROPIC_AUTH_TOKEN` bearer
24/// beside it.
25#[derive(Clone, PartialEq, Eq)]
26pub struct BaseUrlCredential {
27    pub base_url: String,
28    pub bearer: String,
29}
30
31/// Hand-written because the bearer is the account's live credential,
32/// which the derived output would print.
33impl std::fmt::Debug for BaseUrlCredential {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.debug_struct("BaseUrlCredential")
36            .field("base_url", &self.base_url)
37            .field("bearer", &"[redacted]")
38            .finish()
39    }
40}
41
42/// The read of a base-url credential that found no usable
43/// `ANTHROPIC_BASE_URL`.
44#[derive(Debug, PartialEq, Eq, thiserror::Error)]
45#[error("no usable ANTHROPIC_BASE_URL in the account env for the base-url plan")]
46pub struct MissingBase;
47
48/// The base-url credential shared by every base-url provider: the
49/// stamped `ANTHROPIC_BASE_URL` (trimmed, empty after trim = missing)
50/// and `ANTHROPIC_AUTH_TOKEN`, absent = empty bearer - a localhost
51/// proxy ignores the bearer and the probe must still fire.
52pub fn base_url_credential<S: std::hash::BuildHasher>(
53    env: &HashMap<String, String, S>,
54) -> Result<BaseUrlCredential, MissingBase> {
55    let Some(base_url) =
56        env.get("ANTHROPIC_BASE_URL").map(|value| value.trim()).filter(|value| !value.is_empty())
57    else {
58        // Config load rejects a base-url provider with no
59        // ANTHROPIC_BASE_URL (WorkspaceError::AccountProviderNeedsBaseUrl),
60        // so this arm is unreachable in production; the tests below pin
61        // it instead of a debug_assert, which would fire under them.
62        return Err(MissingBase);
63    };
64    let bearer = env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str).unwrap_or_default();
65    Ok(BaseUrlCredential { base_url: base_url.to_owned(), bearer: bearer.to_owned() })
66}
67
68/// Parse a JSON value (string OR number) as a `SystemTime`. Accepts:
69/// - ISO-8601 datetime strings (e.g. `"2025-12-25T12:00:00.000Z"`).
70/// - Integer-string seconds-or-milliseconds since UNIX epoch.
71/// - Number seconds-or-milliseconds since UNIX epoch.
72pub fn parse_timestamp_value(value: &Value) -> Option<SystemTime> {
73    match value {
74        Value::Number(number) => number
75            .as_i64()
76            .or_else(|| number.as_u64().and_then(|raw| i64::try_from(raw).ok()))
77            .and_then(system_time_from_epoch),
78        Value::String(raw) => parse_iso8601_timestamp(raw)
79            .or_else(|| raw.trim().parse::<i64>().ok().and_then(system_time_from_epoch)),
80        _ => None,
81    }
82}
83
84/// Convert a non-negative epoch integer to `SystemTime`. Values
85/// `>= 1e12` are interpreted as milliseconds; smaller values as
86/// seconds. Negative values fail.
87pub fn system_time_from_epoch(raw: i64) -> Option<SystemTime> {
88    if raw < 0 {
89        return None;
90    }
91    let raw = u64::try_from(raw).ok()?;
92    if raw >= 1_000_000_000_000 {
93        Some(SystemTime::UNIX_EPOCH + Duration::from_millis(raw))
94    } else {
95        Some(SystemTime::UNIX_EPOCH + Duration::from_secs(raw))
96    }
97}
98
99/// Hand-rolled ISO-8601 / RFC-3339 datetime parser. Subset:
100/// `YYYY-MM-DD[T| ]HH:MM[:SS[.fffffffff]][Z|+HH:MM|-HH:MM]`. Returns
101/// `None` on any field-level parse failure.
102fn parse_iso8601_timestamp(raw: &str) -> Option<SystemTime> {
103    let trimmed = raw.trim();
104    let (date_part, time_part) = trimmed.split_once('T').or_else(|| trimmed.split_once(' '))?;
105
106    let mut date_iter = date_part.split('-');
107    let year = date_iter.next()?.parse::<i32>().ok()?;
108    let month = date_iter.next()?.parse::<u32>().ok()?;
109    let day = date_iter.next()?.parse::<u32>().ok()?;
110
111    let (time_only, offset_seconds) = split_time_and_offset(time_part)?;
112    let mut time_iter = time_only.split(':');
113    let hour = time_iter.next()?.parse::<u32>().ok()?;
114    let minute = time_iter.next()?.parse::<u32>().ok()?;
115    let second_and_fraction = time_iter.next().unwrap_or("0");
116    let (second_raw, fraction_raw) =
117        second_and_fraction.split_once('.').unwrap_or((second_and_fraction, ""));
118    let second = second_raw.parse::<u32>().ok()?;
119
120    let mut nanos = 0u32;
121    let mut factor = 100_000_000u32;
122    for ch in fraction_raw.chars().take(9) {
123        let digit = ch.to_digit(10)?;
124        nanos = nanos.saturating_add(digit.saturating_mul(factor));
125        if factor == 0 {
126            break;
127        }
128        factor /= 10;
129    }
130
131    let days = days_from_civil(year, month, day)?;
132    let day_seconds =
133        i64::from(hour) * 60 * 60 + i64::from(minute) * 60 + i64::from(second) - offset_seconds;
134    let unix_seconds = days.checked_mul(86_400)?.checked_add(day_seconds)?;
135    if unix_seconds < 0 {
136        return None;
137    }
138    let secs = u64::try_from(unix_seconds).ok()?;
139    Some(
140        SystemTime::UNIX_EPOCH + Duration::from_secs(secs) + Duration::from_nanos(u64::from(nanos)),
141    )
142}
143
144fn split_time_and_offset(raw: &str) -> Option<(&str, i64)> {
145    if let Some(rest) = raw.strip_suffix('Z') {
146        return Some((rest, 0));
147    }
148    let split_idx = raw.rfind(['+', '-'])?;
149    let (time_only, offset_str) = raw.split_at(split_idx);
150    let sign: i64 = if offset_str.starts_with('+') { 1 } else { -1 };
151    let offset_str = &offset_str[1..];
152    let (h, m) = offset_str.split_once(':').unwrap_or((offset_str, "0"));
153    let h: i64 = h.parse().ok()?;
154    let m: i64 = m.parse().ok()?;
155    Some((time_only, sign * (h * 3600 + m * 60)))
156}
157
158/// Days since 1970-01-01 (Howard Hinnant's civil-from-days inverse).
159fn days_from_civil(year: i32, month: u32, day: u32) -> Option<i64> {
160    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
161        return None;
162    }
163    let mut year = i64::from(year);
164    let month = i64::from(month);
165    let day = i64::from(day);
166    year -= i64::from(month <= 2);
167    let era = if year >= 0 { year } else { year - 399 } / 400;
168    let yoe = year - era * 400;
169    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
170    let doe = yoe * 365 + yoe / 4 - yoe / 100 + day_of_year;
171    Some(era * 146_097 + doe - 719_468)
172}
173
174/// The `/api/oauth/usage` endpoint URL. Defaults to the hardcoded
175/// Anthropic host; a `base_url` override (an account's
176/// `ANTHROPIC_BASE_URL`) redirects the probe to an alternate endpoint
177/// serving the same `OauthUsage` shape. Any trailing slash on the
178/// override is trimmed so `http://host/` and `http://host` behave
179/// identically.
180fn usage_url(base_url: Option<&str>) -> String {
181    match base_url {
182        Some(base) => format!("{}/api/oauth/usage", base.trim_end_matches('/')),
183        None => OAUTH_USAGE_URL.to_owned(),
184    }
185}
186
187/// One round-trip against `/api/oauth/usage` with the windowed
188/// request shape: ACCEPT + content-type + the oauth-beta header +
189/// `claude-code/<version>` UA + Bearer. Status classification:
190/// 200 / 401 / 403-with-`oauth_scope_insufficient`-body / 429 +
191/// Retry-After / other. The client and UA arrive injected; building
192/// them is the host port's job.
193pub async fn anthropic_windowed_probe(
194    client: &reqwest::Client,
195    user_agent: &str,
196    base_url: Option<&str>,
197    access_token: &str,
198) -> Result<OauthUsage, OauthUsageError> {
199    let response = client
200        .get(usage_url(base_url))
201        .headers(oauth_headers(user_agent, access_token)?)
202        .send()
203        .await
204        .map_err(|error| OauthUsageError::Network(error.to_string()))?;
205
206    let status = response.status().as_u16();
207    // Parse Retry-After BEFORE consuming the response body - once
208    // we call .bytes() the response object is moved. Anthropic
209    // returns 429 with a per-account hold-down value in seconds;
210    // honouring it prevents the poller from re-tripping the limit
211    // every cycle.
212    let retry_after = if status == 429 {
213        response
214            .headers()
215            .get(reqwest::header::RETRY_AFTER)
216            .and_then(|v| v.to_str().ok())
217            .and_then(parse_retry_after)
218    } else {
219        None
220    };
221    let body = response
222        .bytes()
223        .await
224        .map_err(|error| OauthUsageError::Network(format!("body read: {error}")))?;
225
226    // Diagnostic tracing for the "Anthropic 429s us on the first
227    // probe" suspicion. Log status + a body suffix for every
228    // non-200 response so a triage can correlate "which account /
229    // when / what did the API actually say." Successful 200s are
230    // logged at trace level (high volume - 60 s poll x N accounts)
231    // with no body.
232    if status == 200 {
233        tracing::trace!(
234            target: "forge_gateway::anthropic",
235            event_name = "oauth_usage_response",
236            status,
237            outcome = "ok",
238            body_bytes = body.len(),
239        );
240    } else if status == 403 && is_scope_refusal(&body) {
241        // The verdict on a valid setup token, not a failure: warn here
242        // would fire every 60 s per healthy token account.
243        tracing::debug!(
244            target: "forge_gateway::anthropic",
245            event_name = "oauth_usage_scope_refusal",
246            status,
247            outcome = "scope_refused",
248            body_suffix = %redacted_body(&body, access_token),
249        );
250    } else {
251        tracing::warn!(
252            target: "forge_gateway::anthropic",
253            event_name = "oauth_usage_response",
254            status,
255            outcome = "non_ok",
256            retry_after_secs = ?retry_after.map(|d| d.as_secs()),
257            body_suffix = %redacted_body(&body, access_token),
258        );
259    }
260
261    match status {
262        200 => serde_json::from_slice::<OauthUsage>(&body).map_err(|error| {
263            OauthUsageError::Decode(redacted_message(&error.to_string(), access_token))
264        }),
265        403 if is_scope_refusal(&body) => Err(OauthUsageError::ScopeInsufficient),
266        401 | 403 => Err(OauthUsageError::Unauthorized(status)),
267        429 => Err(OauthUsageError::RateLimited { retry_after, snapshot: None }),
268        _ => Err(OauthUsageError::HttpStatus(status, redacted_body(&body, access_token))),
269    }
270}
271
272fn oauth_headers(user_agent: &str, access_token: &str) -> Result<HeaderMap, OauthUsageError> {
273    let mut headers = HeaderMap::new();
274    headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
275    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
276    headers.insert("anthropic-beta", HeaderValue::from_static(OAUTH_BETA_HEADER));
277    let ua = HeaderValue::from_str(user_agent)
278        .map_err(|error| OauthUsageError::Network(format!("bad UA header: {error}")))?;
279    headers.insert(USER_AGENT, ua);
280    let bearer = HeaderValue::from_str(&format!("Bearer {access_token}"))
281        .map_err(|error| OauthUsageError::Network(format!("bad bearer header: {error}")))?;
282    headers.insert(AUTHORIZATION, bearer);
283    Ok(headers)
284}
285
286/// Whether a 403 body is the usage endpoint's scope refusal rather than
287/// an auth failure. Keyed on the body's `error.details.error_code` -
288/// the verified live shape for a valid setup token; a revoked one
289/// answers 401 `authentication_error`, so the two never share a class.
290fn is_scope_refusal(body: &[u8]) -> bool {
291    let code = serde_json::from_slice::<Value>(body).ok().and_then(|value| {
292        value.get("error")?.get("details")?.get("error_code")?.as_str().map(str::to_owned)
293    });
294    code.as_deref() == Some("oauth_scope_insufficient")
295}
296
297/// RFC 7231 section 7.1.3 `Retry-After` accepts either delta-seconds
298/// (`"120"`) or an HTTP-date (`"Wed, 21 Oct 2015 07:28:00 GMT"`).
299/// Anthropic emits the integer form today, but the spec leaves the
300/// HTTP-date form open and proxies / CDNs in the path may swap shapes.
301/// Try the integer form first; fall back to httpdate parsing and
302/// compute the delta from `now`.
303pub fn parse_retry_after(raw: &str) -> Option<Duration> {
304    let trimmed = raw.trim();
305    if let Ok(secs) = trimmed.parse::<u64>() {
306        return Some(Duration::from_secs(secs));
307    }
308    let when = httpdate::parse_http_date(trimmed).ok()?;
309    when.duration_since(SystemTime::now()).ok()
310}
311
312/// A body as a log field's suffix (`": ..."`, capped at 200 chars), with
313/// the request's credential masked.
314///
315/// Every endpoint here is reached with a token and several sit on base urls
316/// the user configures, so a host that echoes the request - or a payload
317/// carrying its own copy of the key, as `/v1/key` does - would put key
318/// material in a warn (#2268). Three masks cover the shapes a copy arrives
319/// in: the token itself wherever it appears, any quoted run containing its
320/// opening characters, and any bare word containing them (a payload's copy
321/// is truncated, so it is a leading piece of the token rather than the
322/// whole of it).
323pub fn redacted_body(body: &[u8], bearer: &str) -> String {
324    let text = String::from_utf8_lossy(body).trim().replace('\n', " ");
325    if text.is_empty() {
326        return String::new();
327    }
328    // The mask runs over the whole body, before the cap: a log line shows
329    // the first 200 characters of an already-masked body, so the cut can
330    // never hide key material from the mask.
331    let masked = redacted_message(&text, bearer);
332    let shortened = if masked.chars().count() > 200 {
333        let mut out = masked.chars().take(200).collect::<String>();
334        out.push_str("...");
335        out
336    } else {
337        masked
338    };
339    format!(": {shortened}")
340}
341
342/// Text with the credential masked, for a field that is not a body - a
343/// serde message beside one, which can quote a value it was decoding.
344pub fn redacted_message(text: &str, bearer: &str) -> String {
345    // Below the tag's own width the bearer is a placeholder rather than key
346    // material - the localhost-proxy case, whose bearer `base_url_credential`
347    // leaves empty - so only a word that IS the bearer is masked: masking the
348    // words that merely CONTAIN it eats the evidence the line exists for
349    // ("local" in a hostname), while an exact echo still goes.
350    let tag: String = bearer.chars().take(TAG).collect();
351    if tag.chars().count() < TAG {
352        if bearer.is_empty() {
353            return text.to_owned();
354        }
355        return text
356            .split_whitespace()
357            .map(|word| {
358                // A JSON body carries the token with its punctuation
359                // attached (`"hunter2",`), so the comparison trims the
360                // word's ends of everything a key cannot hold.
361                let bare =
362                    word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
363                if bare == bearer { "[redacted]" } else { word }
364            })
365            .collect::<Vec<_>>()
366            .join(" ");
367    }
368    let replaced = text.replace(bearer, "[redacted]");
369    // Every window of the token, not just its opening: a host can break the
370    // key across whitespace or a quote, and the piece that survives still
371    // carries a window of it.
372    let windows: Vec<String> =
373        bearer.chars().collect::<Vec<_>>().windows(TAG).map(|w| w.iter().collect()).collect();
374    let quoted = mask_quoted_runs(&replaced, &windows);
375    // A copy need not be quoted either: a host echoing the request writes it
376    // bare. Whitespace runs normalize here.
377    quoted
378        .split_whitespace()
379        .map(|word| if windows.iter().any(|w| word.contains(w)) { "[redacted]" } else { word })
380        .collect::<Vec<_>>()
381        .join(" ")
382}
383
384/// The width of the token piece a mask matches on: long enough that it is
385/// key material rather than a scheme, short enough to survive a truncation.
386const TAG: usize = 8;
387
388/// Every double-quoted run containing a window of the token masked; an
389/// unterminated quote takes its tail with it rather than printing a value
390/// whose end nobody found. A run's spaces stay inside it, which the
391/// bare-word pass over it cannot see.
392fn mask_quoted_runs(text: &str, windows: &[String]) -> String {
393    let mut out = String::with_capacity(text.len());
394    let mut rest = text;
395    while let Some(open) = rest.find('"') {
396        out.push_str(&rest[..open]);
397        let after = &rest[open + 1..];
398        let Some(close) = after.find('"') else {
399            out.push_str("[redacted]");
400            return out;
401        };
402        let run = &after[..close];
403        if windows.iter().any(|w| run.contains(w)) {
404            out.push_str("[redacted]");
405        } else {
406            out.push('"');
407            out.push_str(run);
408            out.push('"');
409        }
410        rest = &after[close + 1..];
411    }
412    out.push_str(rest);
413    out
414}
415
416/// Map a windowed payload to a snapshot with every window treated as
417/// independently optional, never requiring `five_hour`. This is the
418/// base-url and token path's mapper: an alternate-endpoint proxy emits
419/// each window on its own (`{}`, `{five_hour}`, `{seven_day}`,
420/// `{five_hour, seven_day}`), and a missing `five_hour` is a valid
421/// steady state - the cold start before the first upstream request,
422/// and the post-5h-reset window where the proxy drops the key
423/// entirely - not a malformed response. `{}` maps to all-None (n/a
424/// bars); an out-of-contract error-shaped 200 lands here as n/a too
425/// rather than erroring. Infallible - there is no window this can
426/// reject.
427pub fn snapshot_from_payload_lenient(payload: OauthUsage) -> UsageSnapshot {
428    UsageSnapshot {
429        source: UsageSourceKind::Oauth,
430        fetched_at: SystemTime::now(),
431        five_hour: map_window(payload.five_hour),
432        seven_day: map_window(payload.seven_day),
433        seven_day_opus: map_window(payload.seven_day_opus),
434        seven_day_sonnet: map_window(payload.seven_day_sonnet),
435        extra_usage: map_extra_usage(payload.extra_usage),
436        spend: None,
437        balance: None,
438    }
439}
440
441pub(crate) fn map_window(payload: Option<OauthUsageWindow>) -> Option<UsageWindow> {
442    let payload = payload?;
443    let utilization = payload.utilization?;
444    Some(UsageWindow {
445        utilization: utilization.clamp(0.0, 100.0),
446        resets_at: payload.resets_at.as_ref().and_then(parse_timestamp_value),
447        reset_description: None,
448        status: None,
449    })
450}
451
452pub(crate) fn map_extra_usage(payload: Option<OauthExtraUsage>) -> Option<ExtraUsage> {
453    let payload = payload?;
454    if payload.is_enabled == Some(false) {
455        return None;
456    }
457    Some(ExtraUsage {
458        monthly_limit: payload.monthly_limit.map(|value| value / 100.0),
459        used_credits: payload.used_credits.map(|value| value / 100.0),
460        utilization: payload.utilization.map(|value| value.clamp(0.0, 100.0)),
461        currency: payload.currency,
462    })
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    /// **A log line never carries key material** (#2268): the payloads
470    /// these probes read include a copy of the key - truncated, so it is a
471    /// leading piece of the token rather than the whole of it - and a host
472    /// that echoes the request carries whatever it was sent. The body's
473    /// shape stays; the key does not, in every shape it can arrive in.
474    #[test]
475    fn a_logged_body_never_carries_the_key() {
476        let bearer = "sk-or-v1-0123456789abcdef";
477
478        // The whole key inside the payload, quoted.
479        let whole = br#"{"data":{"key":"sk-or-v1-0123456789abcdef","usage_daily":1.5}}"#;
480        let said = redacted_body(whole, bearer);
481        assert!(!said.contains("0123456789abcdef"), "the key never prints: {said}");
482        assert!(said.contains("usage_daily"), "and the drift's evidence stays: {said}");
483
484        // The truncated copy the endpoint carries: a leading piece.
485        let truncated = br#"{"data":{"key":"sk-or-v1-0123456789","usage_daily":1.5}}"#;
486        let said = redacted_body(truncated, bearer);
487        assert!(!said.contains("sk-or-v1-0123456789"), "a truncated copy never prints: {said}");
488        assert!(said.contains("usage_daily"), "and the evidence stays: {said}");
489
490        // A copy embedded inside a longer run, which a wrong host echoing
491        // the request sends.
492        let echoed = br#"{"error":"invalid key sk-or-v1-0123456789a rejected"}"#;
493        let said = redacted_body(echoed, bearer);
494        assert!(
495            !said.contains("sk-or-v1-0123456789"),
496            "a copy inside a longer run never prints: {said}",
497        );
498
499        // A bare copy, unquoted, in a non-JSON body.
500        let bare = b"upstream said: bad token sk-or-v1-0123456789a, sorry";
501        let said = redacted_body(bare, bearer);
502        assert!(!said.contains("sk-or-v1"), "a bare truncated copy never prints: {said}");
503        assert!(said.contains("upstream said"), "and the host's words stay: {said}");
504
505        // An unquoted occurrence of the whole token.
506        let raw = format!("not json: token {bearer} rejected");
507        let said = redacted_body(raw.as_bytes(), bearer);
508        assert!(!said.contains("0123456789abcdef"), "an unquoted token never prints: {said}");
509
510        // A key sitting past the 200-char cap: the mask runs before it, or
511        // the cut would hide the key from its own redaction.
512        let padded = format!("{} Token {bearer}", "x".repeat(180));
513        let said = redacted_body(padded.as_bytes(), bearer);
514        assert!(
515            !said.contains("0123456789abcdef"),
516            "the cap never hides a key from the mask: {said}",
517        );
518        assert!(said.contains("Token"), "and the evidence before the key stays: {said}");
519
520        // A cut landing INSIDE the token's opening: mask-first means nothing
521        // of the key prints, where capping first would print the fragment.
522        let cut = format!("{} {bearer}", "x".repeat(195));
523        let said = redacted_body(cut.as_bytes(), bearer);
524        assert!(!said.contains("sk-or"), "no fragment of the key survives the cut: {said}");
525
526        // The key broken across whitespace inside a quoted run, which a
527        // wrapping host sends.
528        let split = br#"{"error":"bad sk-or- v1-0123456789abcdef rejected"}"#;
529        let said = redacted_body(split, bearer);
530        assert!(
531            !said.contains("0123456789abcdef"),
532            "a key split across whitespace never prints: {said}",
533        );
534
535        // Broken across a quote boundary.
536        let broken = br#"bad key "sk-"or-v1-0123456789abcdef here"#;
537        let said = redacted_body(broken, bearer);
538        assert!(
539            !said.contains("0123456789abcdef"),
540            "a key broken across a quote never prints: {said}",
541        );
542
543        // A copy keeping only the key's TAIL: the masks cannot assume the
544        // truncation is a lead.
545        let tail = br#"{"data":{"key":"...0123456789abcdef"}}"#;
546        let said = redacted_body(tail, bearer);
547        assert!(!said.contains("0123456789abcdef"), "a tail-only copy never prints: {said}");
548    }
549
550    /// The neighbours a bearer can be, neither a key: an absent one (the
551    /// localhost-proxy default) and a short placeholder. Neither may eat the
552    /// body it is masking, and a short bearer's exact echo still goes.
553    #[test]
554    fn a_placeholder_bearer_never_eats_the_text() {
555        let said = redacted_body(b"upstream refused the request", "");
556        assert_eq!(said, ": upstream refused the request", "an empty bearer masks nothing");
557
558        let said = redacted_body(b"connecting to localhost:18765 failed", "local");
559        assert!(
560            said.contains("localhost:18765"),
561            "a short bearer does not eat the words containing it: {said}",
562        );
563
564        let said = redacted_message("upstream said: hunter2, apparently", "hunter2");
565        assert_eq!(
566            said, "upstream said: [redacted] apparently",
567            "and a word that IS the short bearer is masked, punctuation and all",
568        );
569    }
570
571    /// A serde message beside a body can quote the value it was decoding
572    /// (`invalid type: string "..."`), so it is masked by the same rule.
573    #[test]
574    fn a_redacted_message_never_carries_the_key() {
575        let bearer = "sk-or-v1-0123456789abcdef";
576        let said = redacted_message(
577            "invalid type: string \"sk-or-v1-0123456789abcdef\", expected f64 at line 1 column 8",
578            bearer,
579        );
580        assert!(!said.contains("0123456789abcdef"), "the value never prints: {said}");
581        assert!(said.contains("expected f64"), "and the reason stays: {said}");
582        assert!(said.contains("line 1 column 8"), "with the position: {said}");
583    }
584
585    #[test]
586    fn usage_url_defaults_to_anthropic_host() {
587        assert_eq!(usage_url(None), OAUTH_USAGE_URL);
588    }
589
590    #[test]
591    fn usage_url_uses_base_url_override_and_trims_trailing_slash() {
592        assert_eq!(
593            usage_url(Some("http://localhost:18765")),
594            "http://localhost:18765/api/oauth/usage",
595        );
596        assert_eq!(
597            usage_url(Some("http://localhost:18765/")),
598            "http://localhost:18765/api/oauth/usage",
599            "trailing slash trimmed so host and host/ behave identically",
600        );
601    }
602
603    #[test]
604    fn base_url_credential_reads_the_base_and_bearer_pair() {
605        let mut env = HashMap::new();
606        env.insert("ANTHROPIC_BASE_URL".to_owned(), "http://localhost:18765".to_owned());
607        env.insert("ANTHROPIC_AUTH_TOKEN".to_owned(), "sk-codex".to_owned());
608        // A stray setup token must not leak into the base-url pair: the
609        // base-url providers authenticate with ANTHROPIC_AUTH_TOKEN only.
610        env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_owned(), "setup-token".to_owned());
611        assert_eq!(
612            base_url_credential(&env),
613            Ok(BaseUrlCredential {
614                base_url: "http://localhost:18765".to_owned(),
615                bearer: "sk-codex".to_owned(),
616            }),
617        );
618    }
619
620    #[test]
621    fn base_url_credential_trims_whitespace_around_the_base() {
622        let mut env = HashMap::new();
623        env.insert("ANTHROPIC_BASE_URL".to_owned(), "  http://localhost:18765  ".to_owned());
624        let credential = base_url_credential(&env).expect("credential");
625        assert_eq!(credential.base_url, "http://localhost:18765");
626    }
627
628    /// The bearer is the account's live credential, so a `{:?}` must
629    /// never print it; the base url stays readable.
630    #[test]
631    fn a_credential_never_reaches_the_base_url_credential_debug_output() {
632        let mut env = HashMap::new();
633        env.insert("ANTHROPIC_BASE_URL".to_owned(), "https://proxy.example".to_owned());
634        env.insert("ANTHROPIC_AUTH_TOKEN".to_owned(), "bearer-supersecret".to_owned());
635        let credential = base_url_credential(&env).expect("credential");
636        let rendered = format!("{credential:?}");
637        assert!(!rendered.contains("bearer-supersecret"), "the bearer leaked: {rendered}");
638        assert!(
639            rendered.contains("https://proxy.example"),
640            "the base url stays readable: {rendered}",
641        );
642    }
643
644    /// An absent or blank ANTHROPIC_AUTH_TOKEN must not suppress the
645    /// probe: an empty bearer goes out and a localhost proxy ignores it.
646    #[test]
647    fn base_url_credential_defaults_the_bearer_to_empty() {
648        let mut env = HashMap::new();
649        env.insert("ANTHROPIC_BASE_URL".to_owned(), "http://localhost:18765".to_owned());
650        assert_eq!(base_url_credential(&env).expect("credential").bearer, "");
651        assert_eq!(base_url_credential(&HashMap::new()).expect_err("no base"), MissingBase);
652    }
653
654    #[test]
655    fn base_url_credential_rejects_a_base_that_is_empty_after_trim() {
656        let mut env = HashMap::new();
657        env.insert("ANTHROPIC_BASE_URL".to_owned(), "   ".to_owned());
658        assert_eq!(base_url_credential(&env), Err(MissingBase));
659    }
660
661    /// A down endpoint must surface as the Network class and the probe
662    /// must return rather than hang: preflight's bounded-failure path
663    /// leans on both. Port 1 on loopback refuses the connect at once.
664    #[tokio::test]
665    async fn a_down_endpoint_is_a_network_failure_and_the_probe_returns() {
666        let client = reqwest::Client::builder().build().expect("client");
667        let result = tokio::time::timeout(
668            Duration::from_secs(5),
669            anthropic_windowed_probe(&client, "claude-code/1.0.0", Some("http://127.0.0.1:1"), "t"),
670        )
671        .await
672        .expect("the probe returns against an unreachable endpoint");
673        assert!(
674            matches!(result, Err(OauthUsageError::Network(_))),
675            "a refused connect is the Network class, not a status or decode; got {result:?}"
676        );
677    }
678
679    #[test]
680    fn retry_after_integer_seconds_round_trip() {
681        assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
682        assert_eq!(parse_retry_after("  120  "), Some(Duration::from_secs(120)));
683        assert_eq!(parse_retry_after("3600"), Some(Duration::from_secs(3600)));
684    }
685
686    #[test]
687    fn retry_after_http_date_returns_delta_from_now() {
688        // ~1 hour in the future, formatted in HTTP-date format
689        let target = SystemTime::now() + Duration::from_secs(3600);
690        let formatted = httpdate::fmt_http_date(target);
691        let parsed = parse_retry_after(&formatted).expect("http-date parses");
692        // The parsed delta should be close to 1 hour (allow +-5 s drift).
693        assert!(parsed.as_secs() >= 3595 && parsed.as_secs() <= 3605, "got {parsed:?}");
694    }
695
696    #[test]
697    fn retry_after_past_http_date_returns_none() {
698        // HTTP-date in the past - duration_since(now) returns Err -> None.
699        let past = SystemTime::now() - Duration::from_secs(3600);
700        let formatted = httpdate::fmt_http_date(past);
701        assert!(parse_retry_after(&formatted).is_none());
702    }
703
704    #[test]
705    fn retry_after_garbage_returns_none() {
706        assert!(parse_retry_after("not a duration").is_none());
707        assert!(parse_retry_after("").is_none());
708    }
709
710    #[test]
711    fn decodes_sparse_oauth_payload() {
712        let usage: OauthUsage = serde_json::from_slice(
713            br#"{
714                "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
715                "seven_day_sonnet": { "utilization": 5 },
716                "unknown_field": true
717            }"#,
718        )
719        .expect("decode");
720        assert_eq!(usage.five_hour.as_ref().and_then(|w| w.utilization), Some(12.5));
721        assert_eq!(usage.seven_day_sonnet.as_ref().and_then(|w| w.utilization), Some(5.0));
722        assert!(usage.seven_day.is_none());
723    }
724
725    #[test]
726    fn decodes_extra_usage_in_minor_units() {
727        let usage: OauthUsage = serde_json::from_slice(
728            br#"{
729                "five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
730                "extra_usage": {
731                    "is_enabled": true,
732                    "monthly_limit": 2000,
733                    "used_credits": 1240,
734                    "utilization": 62,
735                    "currency": "USD"
736                }
737            }"#,
738        )
739        .expect("decode");
740        let extra = usage.extra_usage.expect("extra usage");
741        assert_eq!(extra.monthly_limit, Some(2000.0));
742        assert_eq!(extra.used_credits, Some(1240.0));
743        assert_eq!(extra.utilization, Some(62.0));
744        assert_eq!(extra.currency.as_deref(), Some("USD"));
745    }
746
747    #[test]
748    fn lenient_maps_seven_day_only_without_erroring() {
749        // Post-5h-reset steady state: the proxy drops the `five_hour`
750        // key entirely (serde skip) and sends only `seven_day`. That
751        // must map to a snapshot with five_hour None + seven_day Some,
752        // NOT a fetch error - the earlier all-absent-else-strict logic
753        // routed this to the strict mapper and flipped the account to a
754        // fetch error every 5h cycle.
755        let payload: OauthUsage =
756            serde_json::from_slice(br#"{"seven_day":{"utilization":10.0}}"#).expect("decode");
757        let snapshot = snapshot_from_payload_lenient(payload);
758        assert!(snapshot.five_hour.is_none());
759        assert_eq!(snapshot.seven_day.as_ref().map(|window| window.utilization), Some(10.0));
760    }
761
762    #[test]
763    fn lenient_maps_empty_payload_to_all_none_snapshot() {
764        // A base-url account's proxy returns `{}` until warm; that must
765        // become an all-None snapshot (n/a bars), not a fetch error.
766        let snapshot = snapshot_from_payload_lenient(OauthUsage::default());
767        assert!(snapshot.five_hour.is_none());
768        assert!(snapshot.seven_day.is_none());
769        assert!(snapshot.seven_day_opus.is_none());
770        assert!(snapshot.seven_day_sonnet.is_none());
771        assert_eq!(snapshot.source, UsageSourceKind::Oauth);
772    }
773
774    #[test]
775    fn lenient_maps_five_hour_only_populated() {
776        let payload: OauthUsage = serde_json::from_slice(
777            br#"{ "five_hour": { "utilization": 42.0, "resets_at": "2025-12-25T12:00:00.000Z" } }"#,
778        )
779        .expect("decode");
780        let snapshot = snapshot_from_payload_lenient(payload);
781        assert_eq!(snapshot.five_hour.as_ref().map(|window| window.utilization), Some(42.0));
782        assert!(snapshot.seven_day.is_none());
783    }
784
785    #[test]
786    fn maps_extra_usage_amounts_in_major_units() {
787        let payload: OauthUsage = serde_json::from_slice(
788            br#"{
789                "five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
790                "extra_usage": {
791                    "is_enabled": true,
792                    "monthly_limit": 2000,
793                    "used_credits": 1240,
794                    "utilization": 62,
795                    "currency": "USD"
796                }
797            }"#,
798        )
799        .expect("decode");
800        let extra = map_extra_usage(payload.extra_usage).expect("extra usage");
801        assert_eq!(extra.monthly_limit, Some(20.0));
802        assert_eq!(extra.used_credits, Some(12.4));
803        assert_eq!(extra.utilization, Some(62.0));
804        assert_eq!(extra.currency.as_deref(), Some("USD"));
805    }
806
807    #[test]
808    fn map_window_clamps_utilization_and_parses_resets_at() {
809        let payload: OauthUsageWindow = serde_json::from_str(
810            r#"{ "utilization": 140.0, "resets_at": "2025-12-25T12:00:00.000Z" }"#,
811        )
812        .expect("decode");
813        let window = map_window(Some(payload)).expect("window");
814        assert!(
815            (window.utilization - 100.0).abs() < f64::EPSILON,
816            "utilization clamps to the percentage range",
817        );
818        assert_eq!(
819            window.resets_at,
820            Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_766_664_000)),
821        );
822    }
823
824    /// The verified after-usage shape: a scope refusal classifies, an
825    /// authentication_error does not, and neither does a non-JSON body
826    /// or a populated-but-different error_code.
827    #[test]
828    fn a_403_scope_refusal_body_is_recognized_and_other_403s_are_not() {
829        let refused = br#"{"type":"error","error":{"type":"permission_error","message":"OAuth token does not meet scope requirement user:profile","details":{"error_code":"oauth_scope_insufficient"}}}"#;
830        assert!(
831            is_scope_refusal(refused),
832            "the verified refusal shape classifies as a scope refusal"
833        );
834        assert!(
835            !is_scope_refusal(br#"{"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token","details":{}}}"#),
836            "an authentication_error body is not a scope refusal",
837        );
838        assert!(!is_scope_refusal(b"not json"), "a non-JSON body is not a scope refusal");
839        assert!(
840            !is_scope_refusal(br#"{"error":{"type":"permission_error","message":"no details"}}"#),
841            "a permission_error without the error_code is not a scope refusal",
842        );
843        assert!(
844            !is_scope_refusal(
845                br#"{"error":{"details":{"error_code":"oauth_token_revoked"},"message":"x"}}"#
846            ),
847            "a populated but different error_code is not a scope refusal",
848        );
849    }
850
851    #[test]
852    fn parses_iso8601_timestamp() {
853        let parsed = parse_iso8601_timestamp("2025-12-25T12:00:00.000Z").expect("timestamp");
854        assert_eq!(
855            parsed.duration_since(SystemTime::UNIX_EPOCH).expect("after epoch"),
856            Duration::from_secs(1_766_664_000),
857            "2025-12-25T12:00:00Z == epoch second 1766664000"
858        );
859    }
860
861    #[test]
862    fn parses_numeric_millisecond_timestamp() {
863        let parsed =
864            parse_timestamp_value(&serde_json::json!(1_735_128_000_000_i64)).expect("timestamp");
865        assert_eq!(
866            parsed.duration_since(SystemTime::UNIX_EPOCH).expect("after epoch"),
867            Duration::from_secs(1_735_128_000),
868            "milliseconds must land on the matching epoch second"
869        );
870    }
871
872    /// A negative UTC offset applies its true sign: the same wall
873    /// clock written in -05:30 lands 19_800s LATER than the Z form.
874    #[test]
875    fn parses_negative_offset_timestamp() {
876        let parsed = parse_iso8601_timestamp("2025-12-25T06:30:00-05:30").expect("timestamp");
877        assert_eq!(
878            parsed.duration_since(SystemTime::UNIX_EPOCH).expect("after epoch"),
879            Duration::from_secs(1_766_664_000),
880            "06:30-05:30 is the same instant as 12:00Z"
881        );
882    }
883
884    /// Pre-epoch instants are rejected on purpose: `SystemTime +
885    /// Duration` cannot represent them, so a negative epoch returns
886    /// None rather than silently wrapping or clamping.
887    #[test]
888    fn rejects_pre_epoch_timestamps() {
889        assert!(parse_iso8601_timestamp("1969-12-31T23:59:59.000Z").is_none());
890    }
891}