feat: port the data layer to ureq with the Windows system proxy

Replace the WinHTTP client with a ureq agent that keeps the same
Client/Response surface: status errors come back as responses, gzip,
60 s connect / 30 s receive / 120 s total timeouts, rustls with the
platform verifier and a per-request User-Agent override.

Environment proxies come from ureq. On Windows, when none is set, the
static Internet Settings proxy is read from HKCU and parsed by a pure
core module (per-scheme ProxyServer, ProxyOverride globs and <local>);
bypassed hosts go through a direct agent. PAC/WPAD is not evaluated.

The usage providers and credential sources compile again on every
platform. Refresh keeps the Windows candidates and CREATE_NO_WINDOW;
Unix spawns claude/codex from PATH plus the usual per-user install
directories and reaps the child on a detached thread. An empty
CODEX_HOME now counts as unset and a float expiresAt is truncated
instead of ignored.

Fixture tests cover the OAuth usage body, rate-limit headers (including
a 429 over loopback), the ChatGPT envelope, ISO 8601 edge cases,
credential JSON and proxy strings. An ignored live_poll test polls the
real providers.
This commit is contained in:
tiennm99 committed 2026-10-21 12:41:08 +07:00
1 parent 10c43d76af
commit 9e2666ff0e
34 files changed
+1686 -512

No files matched your search

+138 -12
View File
@@ -17,10 +17,7 @@ const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
const BETA_HEADER: &str = "oauth-2025-04-20";
const API_VERSION: &str = "2023-06-01";
const PROBE_MODELS: &[&str] = &[
"claude-3-haiku-20240307",
"claude-haiku-4-5-20251001",
];
const PROBE_MODELS: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"];
pub struct ClaudeProvider {
locator: Locator,
@@ -86,13 +83,16 @@ fn try_usage_endpoint(http: &Client, token: &str) -> Result<Option<UsageWindows>
if !(200..300).contains(&resp.status()) {
return Ok(None);
}
let body: OauthUsage = match resp.json() {
Ok(b) => b,
Err(_) => return Ok(None),
};
Ok(parse_usage_body(resp.body()))
}
/// Windows from an `/api/oauth/usage` body, or `None` when the body does not
/// match the expected shape (which sends the caller to the Messages fallback).
fn parse_usage_body(body: &[u8]) -> Option<UsageWindows> {
let body: OauthUsage = serde_json::from_slice(body).ok()?;
let primary = body.five_hour.map(bucket_to_window).unwrap_or_default();
let secondary = body.seven_day.map(bucket_to_window).unwrap_or_default();
Ok(Some(UsageWindows { primary, secondary }))
Some(UsageWindows { primary, secondary })
}
fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Error> {
@@ -120,8 +120,12 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
return Err(Error::AuthRequired);
}
// Even an error response from Messages can carry rate-limit headers.
if resp.header("anthropic-ratelimit-unified-5h-utilization").is_some()
|| resp.header("anthropic-ratelimit-unified-7d-utilization").is_some()
if resp
.header("anthropic-ratelimit-unified-5h-utilization")
.is_some()
|| resp
.header("anthropic-ratelimit-unified-7d-utilization")
.is_some()
{
return Ok(headers::parse_anthropic(&resp));
}
@@ -240,7 +244,7 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
}
fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
// --- JSON shape ---
@@ -256,3 +260,125 @@ struct Bucket {
utilization: f64,
resets_at: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn at(secs: u64) -> Option<SystemTime> {
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
fn fixture(name: &str) -> Vec<u8> {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"))
}
#[test]
fn full_usage_body_maps_both_buckets() {
let w = parse_usage_body(&fixture("anthropic-usage-full.json")).expect("parses");
assert_eq!(w.primary.utilization, 42.5);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
assert!(has_reset_times(&w));
}
#[test]
fn partial_usage_body_keeps_percentages_and_merges_fallback_resets() {
let w = parse_usage_body(&fixture("anthropic-usage-partial.json")).expect("parses");
assert_eq!(w.primary.utilization, 7.0);
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.resets_at, at(1_709_251_199));
assert!(!has_reset_times(&w));
let fallback = UsageWindows {
primary: Window {
utilization: 99.0,
resets_at: at(10),
},
secondary: Window {
utilization: 99.0,
resets_at: at(20),
},
};
let merged = merge_resets(w, fallback);
assert_eq!(merged.primary.utilization, 7.0, "percent is not replaced");
assert_eq!(merged.primary.resets_at, at(10));
assert_eq!(merged.secondary.resets_at, at(1_709_251_199));
}
#[test]
fn missing_bucket_defaults_to_empty_window() {
let w = parse_usage_body(&fixture("anthropic-usage-five-hour-only.json")).expect("parses");
assert_eq!(w.primary.utilization, 3.0);
assert_eq!(w.secondary.utilization, 0.0);
assert_eq!(w.secondary.resets_at, None);
}
#[test]
fn null_buckets_parse_as_empty_windows() {
let w = parse_usage_body(&fixture("anthropic-usage-null-buckets.json")).expect("parses");
assert_eq!(w.primary.utilization, 0.0);
assert_eq!(w.secondary.utilization, 0.0);
assert!(!has_reset_times(&w));
}
#[test]
fn null_utilization_rejects_body_so_fallback_runs() {
assert!(parse_usage_body(&fixture("anthropic-usage-null-utilization.json")).is_none());
assert!(parse_usage_body(b"not json").is_none());
}
#[test]
fn iso8601_accepts_utc_offsets_and_fractions() {
assert_eq!(parse_iso8601("2025-10-21T10:00:00Z"), at(1_761_040_800));
assert_eq!(
parse_iso8601("2025-10-21T10:00:00.999999+00:00"),
at(1_761_040_800)
);
assert_eq!(
parse_iso8601("2025-10-27T03:00:00+07:00"),
at(1_761_508_800)
);
assert_eq!(
parse_iso8601("2025-10-21T05:30:00-04:30"),
at(1_761_040_800)
);
assert_eq!(parse_iso8601("2025-10-21T10:00:00"), at(1_761_040_800));
assert_eq!(parse_iso8601("2024-02-29T23:59:59Z"), at(1_709_251_199));
assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), at(0));
}
#[test]
fn iso8601_rejects_malformed_values() {
assert_eq!(
parse_iso8601("2025-10-21T10:00:00+0000"),
None,
"colonless offset"
);
assert_eq!(parse_iso8601("2025-10-21t10:00:00Z"), None, "lowercase t");
assert_eq!(parse_iso8601("1969-12-31T23:59:59Z"), None, "before 1970");
assert_eq!(
parse_iso8601("1970-01-01T00:30:00+01:00"),
None,
"negative epoch"
);
assert_eq!(
parse_iso8601("2025-02-29T00:00:00Z"),
None,
"not a leap year"
);
assert_eq!(parse_iso8601("2025-13-01T00:00:00Z"), None);
assert_eq!(parse_iso8601("2025-10-21 10:00:00Z"), None);
assert_eq!(parse_iso8601("2025-10-21T10:00Z"), None);
assert_eq!(parse_iso8601(""), None);
}
#[test]
fn token_expiry_compares_milliseconds() {
assert!(!token_is_expired(None));
assert!(token_is_expired(Some(0)));
assert!(!token_is_expired(Some(i64::MAX)));
}
}
+49
View File
@@ -125,3 +125,52 @@ impl<T> FlattenBoxed<T> for Option<Option<Box<T>>> {
self.and_then(|inner| inner.map(|b| *b))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(secs: u64) -> Option<SystemTime> {
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
fn envelope(name: &str) -> Envelope {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
serde_json::from_str(&text).expect("fixture parses")
}
#[test]
fn full_envelope_maps_both_windows() {
let w = envelope_to_windows(envelope("chatgpt-usage-full.json")).expect("rate_limit");
assert_eq!(w.primary.utilization, 12.0);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
}
#[test]
fn null_rate_limit_is_a_bad_response() {
assert!(envelope_to_windows(envelope("chatgpt-usage-null-rate-limit.json")).is_none());
let missing: Envelope = serde_json::from_str("{}").expect("parses");
assert!(envelope_to_windows(missing).is_none());
}
#[test]
fn null_window_defaults_to_empty() {
let w =
envelope_to_windows(envelope("chatgpt-usage-null-windows.json")).expect("rate_limit");
assert_eq!(w.primary.utilization, 55.0);
assert_eq!(w.secondary.utilization, 0.0);
assert_eq!(w.secondary.resets_at, None);
}
#[test]
fn negative_reset_has_no_reset_time_and_percent_is_clamped() {
let w =
envelope_to_windows(envelope("chatgpt-usage-negative-reset.json")).expect("rate_limit");
assert_eq!(w.primary.utilization, 0.0);
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.resets_at, at(0));
}
}
+92
View File
@@ -53,3 +53,95 @@ fn unix_to_systemtime(secs: Option<i64>) -> Option<SystemTime> {
}
Some(UNIX_EPOCH + Duration::from_secs(s as u64))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::test_server::serve_once;
use crate::net::{Client, USER_AGENT};
fn at(secs: u64) -> Option<SystemTime> {
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
#[test]
fn fractions_scale_to_percent_and_resets_parse() {
let resp = Response::from_parts(
200,
&[
("anthropic-ratelimit-unified-5h-utilization", "0.425"),
("anthropic-ratelimit-unified-5h-reset", "1761040800"),
("Anthropic-Ratelimit-Unified-7d-Utilization", "1.2"),
("anthropic-ratelimit-unified-7d-reset", "1761508800"),
],
b"",
);
let w = parse_anthropic(&resp);
assert!((w.primary.utilization - 42.5).abs() < 1e-9);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_eq!(w.secondary.utilization, 100.0, "clamped to 100");
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
}
#[test]
fn missing_or_garbage_values_fall_back_to_empty() {
let resp = Response::from_parts(
200,
&[
("anthropic-ratelimit-unified-5h-utilization", "abc"),
("anthropic-ratelimit-unified-5h-reset", "soon"),
],
b"",
);
let w = parse_anthropic(&resp);
assert_eq!(w.primary.utilization, 0.0);
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.utilization, 0.0);
assert_eq!(w.secondary.resets_at, None);
}
#[test]
fn negative_values_clamp_and_drop_reset() {
let resp = Response::from_parts(
200,
&[
("anthropic-ratelimit-unified-5h-utilization", "-0.3"),
("anthropic-ratelimit-unified-5h-reset", "-1"),
("anthropic-ratelimit-unified-7d-utilization", "0.5"),
("anthropic-ratelimit-unified-7d-reset", "0"),
],
b"",
);
let w = parse_anthropic(&resp);
assert_eq!(w.primary.utilization, 0.0);
assert_eq!(w.primary.resets_at, None);
assert_eq!(w.secondary.utilization, 50.0);
assert_eq!(w.secondary.resets_at, at(0));
}
#[test]
fn rate_limited_reply_still_yields_headers_over_http() {
let served = serve_once(
"429 Too Many Requests",
&[
("anthropic-ratelimit-unified-5h-utilization", "1.0"),
("anthropic-ratelimit-unified-5h-reset", "1761040800"),
("anthropic-ratelimit-unified-7d-utilization", "0.25"),
("anthropic-ratelimit-unified-7d-reset", "1761508800"),
],
br#"{"type":"error","error":{"type":"rate_limit_error"}}"#.to_vec(),
);
let client = Client::without_proxy(USER_AGENT);
let resp = client
.post(&served.base)
.json_body(&serde_json::json!({"max_tokens": 1}))
.and_then(crate::net::RequestBuilder::send)
.expect("429 is a response, not an error");
assert_eq!(resp.status(), 429);
let w = parse_anthropic(&resp);
assert_eq!(w.primary.utilization, 100.0);
assert_eq!(w.primary.resets_at, at(1_761_040_800));
assert_eq!(w.secondary.utilization, 25.0);
assert_eq!(w.secondary.resets_at, at(1_761_508_800));
}
}
+31 -3
View File
@@ -1,7 +1,35 @@
// Usage subsystem. Only the shared data shapes are compiled for now; the
// providers, registry, headers and refresh orchestrator return with the
// HTTP client port.
// Usage subsystem: the provider trait, the per-provider implementations,
// the registry that polls them and the credential refresh orchestrator.
pub mod anthropic;
pub mod chatgpt;
pub mod headers;
pub mod refresh;
pub mod registry;
pub mod types;
pub use registry::Registry;
pub use types::{ProviderId, UsageWindows, Window};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("authentication required")]
AuthRequired,
#[error("no credentials configured")]
NoCredentials,
#[error("token expired after refresh attempt")]
TokenExpired,
#[error("network: {0}")]
Network(#[from] crate::net::Error),
#[error("credentials: {0}")]
Creds(#[from] crate::creds::Error),
#[error("unexpected response shape: {0}")]
BadResponse(String),
}
/// Every provider exposes a stable identity and a sync `poll` that performs
/// HTTP calls against the supplied client.
pub trait UsageProvider: Send {
fn id(&self) -> ProviderId;
fn poll(&mut self, http: &crate::net::Client) -> Result<UsageWindows, Error>;
}
+220 -34
View File
@@ -4,14 +4,22 @@
// which CLI to spawn. We invoke that CLI and watch the credential file's
// signature; if it changes within the timeout we declare success.
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use crate::creds::{CredentialSource, RefreshHint};
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Directories searched after `PATH` on Unix. A desktop session often starts
/// GUI apps with a shorter `PATH` than the login shell, which misses the
/// usual per-user install locations of the Claude and Codex CLIs.
#[cfg(unix)]
const EXTRA_HOME_DIRS: &[&str] = &[".local/bin", ".claude/local"];
#[cfg(unix)]
const EXTRA_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Refreshed,
@@ -55,54 +63,232 @@ impl Orchestrator {
}
fn spawn_cli(hint: &RefreshHint) -> bool {
let (candidates, args) = cli_for(hint);
spawn_first(candidates, args)
}
#[cfg(windows)]
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
match hint {
RefreshHint::LocalClaudeCli => {
spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."])
}
RefreshHint::LocalCodexCli => spawn_local(
RefreshHint::LocalClaudeCli => (&["claude.cmd", "claude.exe", "claude"], &["-p", "."]),
RefreshHint::LocalCodexCli => (
&["codex.cmd", "codex.ps1", "codex.exe", "codex"],
&["exec", "."],
),
}
}
fn spawn_local(candidates: &[&str], args: &[&str]) -> bool {
#[cfg(unix)]
fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) {
match hint {
RefreshHint::LocalClaudeCli => (&["claude"], &["-p", "."]),
RefreshHint::LocalCodexCli => (&["codex"], &["exec", "."]),
}
}
/// Spawn the first candidate that starts. The child is detached: a reaper
/// thread waits on it so no zombie is left behind.
fn spawn_first(candidates: &[&str], args: &[&str]) -> bool {
for name in candidates {
let lower = name.to_ascii_lowercase();
let mut cmd = if lower.ends_with(".ps1") {
let mut c = Command::new("powershell.exe");
c.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
.arg(name);
for a in args {
c.arg(a);
}
c
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
let mut c = Command::new("cmd.exe");
c.arg("/c").arg(name);
for a in args {
c.arg(a);
}
c
} else {
let mut c = Command::new(name);
for a in args {
c.arg(a);
}
c
let Some(mut cmd) = command_for(name, args) else {
continue;
};
cmd.env_remove("CLAUDECODE")
.env_remove("CLAUDE_CODE_ENTRYPOINT")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if cmd.spawn().is_ok() {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
if let Ok(child) = cmd.spawn() {
reap_detached(child);
return true;
}
}
false
}
fn reap_detached(mut child: Child) {
std::thread::spawn(move || {
if let Err(err) = child.wait() {
log::warn!("refresh CLI could not be reaped: {err}");
}
});
}
#[cfg(windows)]
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
let lower = name.to_ascii_lowercase();
let mut cmd = if lower.ends_with(".ps1") {
let mut c = Command::new("powershell.exe");
c.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
.arg(name);
c
} else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
let mut c = Command::new("cmd.exe");
c.arg("/c").arg(name);
c
} else {
Command::new(name)
};
cmd.args(args);
Some(cmd)
}
#[cfg(unix)]
fn command_for(name: &str, args: &[&str]) -> Option<Command> {
let path_var = std::env::var_os("PATH");
let home = dirs::home_dir();
let dirs = search_dirs(path_var.as_deref(), home.as_deref());
let program = find_executable(name, &dirs)?;
let mut cmd = Command::new(program);
cmd.args(args);
Some(cmd)
}
/// `PATH` entries in order, then the extra install locations, without
/// duplicates. Empty and relative entries are skipped so the working
/// directory is never searched.
#[cfg(unix)]
fn search_dirs(
path_var: Option<&std::ffi::OsStr>,
home: Option<&std::path::Path>,
) -> Vec<std::path::PathBuf> {
use std::path::PathBuf;
let mut out: Vec<PathBuf> = Vec::new();
let from_path = path_var.map(|p| std::env::split_paths(p).collect::<Vec<_>>());
let extras_home = home
.into_iter()
.flat_map(|h| EXTRA_HOME_DIRS.iter().map(move |d| h.join(d)));
let extras_system = EXTRA_SYSTEM_DIRS.iter().map(PathBuf::from);
for dir in from_path
.unwrap_or_default()
.into_iter()
.chain(extras_home)
.chain(extras_system)
{
if dir.is_absolute() && !out.contains(&dir) {
out.push(dir);
}
}
out
}
/// First `dir/name` that is a regular file with an execute bit set.
#[cfg(unix)]
fn find_executable(name: &str, dirs: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
use std::os::unix::fs::PermissionsExt;
dirs.iter().map(|d| d.join(name)).find(|candidate| {
std::fs::metadata(candidate)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
})
}
#[cfg(all(test, unix))]
mod tests {
use std::ffi::OsString;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ccub-refresh-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn write_file(path: &Path, mode: u32) {
std::fs::write(path, "#!/bin/sh\n").expect("write");
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod");
}
#[test]
fn search_dirs_puts_path_first_then_extras_without_duplicates() {
let path = OsString::from("/usr/bin::relative/bin:/usr/local/bin:/usr/bin");
let dirs = search_dirs(Some(&path), Some(Path::new("/home/u")));
let expected: Vec<PathBuf> = [
"/usr/bin",
"/usr/local/bin",
"/home/u/.local/bin",
"/home/u/.claude/local",
"/opt/homebrew/bin",
]
.iter()
.map(PathBuf::from)
.collect();
assert_eq!(dirs, expected);
}
#[test]
fn search_dirs_without_path_or_home_keeps_system_extras() {
let dirs = search_dirs(None, None);
assert_eq!(
dirs,
vec![
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin")
]
);
}
#[test]
fn find_executable_skips_missing_and_non_executable_files() {
let first = temp_dir("first");
let second = temp_dir("second");
write_file(&first.join("claude"), 0o644);
std::fs::create_dir_all(first.join("codex")).expect("dir named like the CLI");
write_file(&second.join("claude"), 0o755);
let dirs = vec![first.clone(), second.clone()];
assert_eq!(
find_executable("claude", &dirs),
Some(second.join("claude"))
);
assert_eq!(find_executable("codex", &dirs), None);
assert_eq!(find_executable("absent", &dirs), None);
let _ = std::fs::remove_dir_all(first);
let _ = std::fs::remove_dir_all(second);
}
#[test]
fn spawned_child_is_reaped() {
let dir = temp_dir("spawn");
let script = dir.join("fake-cli");
let marker = dir.join("ran");
std::fs::write(
&script,
format!("#!/bin/sh\necho \"$@\" > '{}'\n", marker.display()),
)
.expect("write script");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let name = script.to_str().expect("utf8 path");
assert!(spawn_first(
&["definitely-not-a-cli-name", name],
&["-p", "."]
));
let deadline = Instant::now() + Duration::from_secs(10);
while !marker.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
let written = std::fs::read_to_string(&marker).unwrap_or_default();
assert_eq!(written.trim(), "-p .");
assert!(!spawn_first(&["definitely-not-a-cli-name"], &[]));
let _ = std::fs::remove_dir_all(dir);
}
}
+47 -1
View File
@@ -4,9 +4,9 @@
// every cycle. The result is a flat list of `(id, Result<UsageWindows>)`
// pairs the app can apply to its state.
use crate::core::settings::Settings;
use crate::creds::Locator;
use crate::net::Client;
use crate::settings::Settings;
use crate::usage::{
anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider,
UsageWindows,
@@ -74,3 +74,49 @@ impl Registry {
sigs
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::USER_AGENT;
#[test]
fn live_usage_excludes_opencode_go() {
// `poll_enabled` relies on this for its `unreachable!` arm.
assert!(!ProviderId::LIVE_USAGE.contains(&ProviderId::OpenCodeGo));
for provider in ProviderId::ALL {
assert_eq!(
ProviderId::LIVE_USAGE.contains(&provider),
provider.metadata().live_usage
);
}
}
/// Polls the real providers with the credentials on this machine. Run by
/// hand: `cargo test -- --ignored live_poll --nocapture`.
#[test]
#[ignore = "talks to the real provider endpoints"]
fn live_poll() {
let http = Client::new(USER_AGENT).expect("client");
let mut settings = Settings::default();
for provider in ProviderId::ALL {
settings.set_provider_enabled(provider, true);
}
let mut registry = Registry::with_defaults();
let results = registry.poll_enabled(&http, &settings);
assert_eq!(results.len(), ProviderId::LIVE_USAGE.len());
for (provider, result) in results {
match result {
Ok(w) => println!(
"{}: 5h {:.1}% resets {:?}, weekly {:.1}% resets {:?}",
provider.slug(),
w.primary.utilization,
w.primary.resets_at,
w.secondary.utilization,
w.secondary.resets_at
),
Err(err) => println!("{}: {err}", provider.slug()),
}
}
}
}