1use std::collections::HashMap;
9
10use async_trait::async_trait;
11use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT};
12use serde_json::json;
13
14use forge_primitives::usage::oauth::OauthUsageError;
15use forge_primitives::usage::{UsageSnapshot, UsageSourceKind, UsageWindow};
16
17use crate::helpers::{OAUTH_TIMEOUT, parse_retry_after, redacted_body, system_time_from_epoch};
18use crate::{AccountEnv, BillingModel, ProbeError, Provider, ProviderBackend, ProviderHost};
19
20pub const CLAUDE_CODE_OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN";
26
27pub fn token_bearer<S: std::hash::BuildHasher>(env: &HashMap<String, String, S>) -> Option<&str> {
31 env.get(CLAUDE_CODE_OAUTH_TOKEN_ENV).map(String::as_str).filter(|token| !token.is_empty())
32}
33
34pub struct Anthropic;
36
37#[async_trait]
38impl ProviderBackend for Anthropic {
39 fn token(&self) -> Provider {
40 Provider::Anthropic
41 }
42
43 fn billing(&self) -> BillingModel {
44 BillingModel::Windows
45 }
46
47 fn source(&self) -> UsageSourceKind {
48 UsageSourceKind::Oauth
49 }
50
51 async fn probe(
52 &self,
53 account: &AccountEnv<'_>,
54 host: &dyn ProviderHost,
55 ) -> Result<UsageSnapshot, ProbeError> {
56 let Some(bearer) = token_bearer(account.env) else {
57 return Err(ProbeError::NoCredentials);
58 };
59 let ua = host.user_agent().await.map_err(OauthUsageError::UaProbe)?;
60 let client = host.http_client(OAUTH_TIMEOUT).map_err(OauthUsageError::Network)?;
61 let headers =
62 messages_probe(&client, &ua, None, bearer).await.map_err(ProbeError::Fetch)?;
63 tracing::info!(
64 target: "forge_gateway::anthropic",
65 event_name = "unified_usage_probe_settled",
66 outcome = "ok",
67 "token account unified usage probe settled",
68 );
69 Ok(snapshot_from_unified_headers(&headers))
70 }
71}
72
73const PROBE_MODEL: &str = "claude-haiku-4-5";
76
77const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
78const UNIFIED_PREFIX: &str = "anthropic-ratelimit-unified-";
79
80fn messages_url(base_url: Option<&str>) -> String {
84 match base_url {
85 Some(base) => format!("{}/v1/messages", base.trim_end_matches('/')),
86 None => MESSAGES_URL.to_owned(),
87 }
88}
89
90fn messages_headers(user_agent: &str, access_token: &str) -> Result<HeaderMap, OauthUsageError> {
91 let mut headers = HeaderMap::new();
92 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
93 headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
94 let ua = HeaderValue::from_str(user_agent)
95 .map_err(|error| OauthUsageError::Network(format!("bad UA header: {error}")))?;
96 headers.insert(USER_AGENT, ua);
97 let bearer = HeaderValue::from_str(&format!("Bearer {access_token}"))
98 .map_err(|error| OauthUsageError::Network(format!("bad bearer header: {error}")))?;
99 headers.insert(AUTHORIZATION, bearer);
100 Ok(headers)
101}
102
103async fn messages_probe(
111 client: &reqwest::Client,
112 user_agent: &str,
113 base_url: Option<&str>,
114 access_token: &str,
115) -> Result<HeaderMap, OauthUsageError> {
116 let payload = json!({
117 "model": PROBE_MODEL,
118 "max_tokens": 1,
119 "messages": [{ "role": "user", "content": "hi" }],
120 });
121 let response = client
122 .post(messages_url(base_url))
123 .headers(messages_headers(user_agent, access_token)?)
124 .json(&payload)
125 .send()
126 .await
127 .map_err(|error| OauthUsageError::Network(error.to_string()))?;
128
129 let status = response.status().as_u16();
130 let retry_after = if status == 429 {
133 response
134 .headers()
135 .get(reqwest::header::RETRY_AFTER)
136 .and_then(|value| value.to_str().ok())
137 .and_then(parse_retry_after)
138 } else {
139 None
140 };
141 let headers = response.headers().clone();
142 let body = response
143 .bytes()
144 .await
145 .map_err(|error| OauthUsageError::Network(format!("body read: {error}")))?;
146
147 if status == 200 {
148 tracing::debug!(
149 target: "forge_gateway::anthropic",
150 event_name = "unified_usage_response",
151 status,
152 outcome = "ok",
153 body_bytes = body.len(),
154 five_hour_status = %unified_header_value(&headers, "5h-status"),
155 seven_day_status = %unified_header_value(&headers, "7d-status"),
156 unified_status = %unified_header_value(&headers, "status"),
157 unified_fallback = %unified_header_value(&headers, "fallback"),
158 overage_status = %unified_header_value(&headers, "overage-status"),
159 );
160 } else {
161 tracing::warn!(
165 target: "forge_gateway::anthropic",
166 event_name = "unified_usage_response",
167 status,
168 outcome = "non_ok",
169 retry_after_secs = ?retry_after.map(|duration| duration.as_secs()),
170 five_hour_status = %unified_header_value(&headers, "5h-status"),
171 seven_day_status = %unified_header_value(&headers, "7d-status"),
172 unified_status = %unified_header_value(&headers, "status"),
173 body_suffix = %redacted_body(&body, access_token),
174 );
175 }
176
177 match status {
178 200 => Ok(headers),
179 401 | 403 => Err(OauthUsageError::Unauthorized(status)),
180 429 => Err(OauthUsageError::RateLimited {
181 retry_after,
182 snapshot: rate_limited_snapshot(&headers).map(Box::new),
183 }),
184 _ => Err(OauthUsageError::HttpStatus(status, redacted_body(&body, access_token))),
185 }
186}
187
188fn rate_limited_snapshot(headers: &HeaderMap) -> Option<UsageSnapshot> {
192 let snapshot = snapshot_from_unified_headers(headers);
193 (snapshot.five_hour.is_some() || snapshot.seven_day.is_some()).then_some(snapshot)
194}
195
196fn unified_header_value(headers: &HeaderMap, suffix: &str) -> String {
197 headers
198 .get(format!("{UNIFIED_PREFIX}{suffix}"))
199 .and_then(|value| value.to_str().ok())
200 .unwrap_or_default()
201 .to_owned()
202}
203
204fn unified_window(headers: &HeaderMap, window: &str) -> Option<UsageWindow> {
209 let utilization = headers
210 .get(format!("{UNIFIED_PREFIX}{window}-utilization"))
211 .and_then(|value| value.to_str().ok())
212 .and_then(|value| value.trim().parse::<f64>().ok())
213 .filter(|raw| !raw.is_nan())
217 .map(|raw| (raw * 100.0).clamp(0.0, 100.0))?;
218 let resets_at = headers
219 .get(format!("{UNIFIED_PREFIX}{window}-reset"))
220 .and_then(|value| value.to_str().ok())
221 .and_then(|value| value.trim().parse::<i64>().ok())
222 .and_then(system_time_from_epoch);
223 let status = headers
224 .get(format!("{UNIFIED_PREFIX}{window}-status"))
225 .and_then(|value| value.to_str().ok())
226 .map(str::trim)
227 .filter(|value| !value.is_empty())
228 .map(str::to_owned);
229 Some(UsageWindow { utilization, resets_at, reset_description: None, status })
230}
231
232fn snapshot_from_unified_headers(headers: &HeaderMap) -> UsageSnapshot {
237 UsageSnapshot {
238 source: UsageSourceKind::Oauth,
239 fetched_at: std::time::SystemTime::now(),
240 five_hour: unified_window(headers, "5h"),
241 seven_day: unified_window(headers, "7d"),
242 seven_day_opus: None,
243 seven_day_sonnet: None,
244 extra_usage: None,
245 spend: None,
246 balance: None,
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use std::time::{Duration, SystemTime};
253
254 use async_trait::async_trait;
255
256 use super::*;
257
258 #[test]
259 fn token_bearer_takes_a_non_empty_env_token() {
260 let mut env = HashMap::new();
261 env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_owned(), "setup-token".to_owned());
262 assert_eq!(token_bearer(&env), Some("setup-token"));
263 }
264
265 #[test]
269 fn token_bearer_rejects_blank_tokens() {
270 let mut env = HashMap::new();
271 env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_owned(), String::new());
272 assert_eq!(token_bearer(&env), None);
273 assert_eq!(token_bearer(&HashMap::new()), None);
274 }
275
276 fn unified_header_map() -> HeaderMap {
277 let mut headers = HeaderMap::new();
278 for (name, value) in [
279 ("anthropic-ratelimit-unified-5h-status", "allowed"),
280 ("anthropic-ratelimit-unified-5h-utilization", "0.53"),
281 ("anthropic-ratelimit-unified-5h-reset", "1766664000"),
282 ("anthropic-ratelimit-unified-7d-status", "allowed"),
283 ("anthropic-ratelimit-unified-7d-utilization", "0.06"),
284 ("anthropic-ratelimit-unified-7d-reset", "1767268800"),
285 ] {
286 headers.insert(name, HeaderValue::from_static(value));
287 }
288 headers
289 }
290
291 #[test]
295 fn unified_headers_map_both_windows() {
296 let snapshot = snapshot_from_unified_headers(&unified_header_map());
297 let five_hour = snapshot.five_hour.expect("five hour window");
298 assert!((five_hour.utilization - 53.0).abs() < 1e-9, "got {}", five_hour.utilization);
299 assert_eq!(
300 five_hour.resets_at,
301 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_766_664_000)),
302 );
303 assert_eq!(five_hour.status.as_deref(), Some("allowed"), "the window's own verdict");
304 let seven_day = snapshot.seven_day.expect("seven day window");
305 assert!((seven_day.utilization - 6.0).abs() < 1e-9, "got {}", seven_day.utilization);
306 assert_eq!(
307 seven_day.resets_at,
308 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_268_800)),
309 );
310 assert_eq!(seven_day.status.as_deref(), Some("allowed"), "the window's own verdict");
311 assert!(snapshot.seven_day_opus.is_none());
312 assert!(snapshot.seven_day_sonnet.is_none());
313 assert_eq!(snapshot.source, UsageSourceKind::Oauth);
314 }
315
316 #[test]
321 fn a_nan_utilization_leaves_the_window_unreported() {
322 let mut headers = HeaderMap::new();
323 headers
324 .insert("anthropic-ratelimit-unified-5h-utilization", HeaderValue::from_static("nan"));
325
326 assert!(unified_window(&headers, "5h").is_none(), "a NaN is not a reading");
327 }
328
329 #[test]
333 fn an_infinite_utilization_still_clamps_to_the_range() {
334 let mut headers = HeaderMap::new();
335 headers
336 .insert("anthropic-ratelimit-unified-5h-utilization", HeaderValue::from_static("inf"));
337
338 let window = unified_window(&headers, "5h").expect("an infinity is a reading, clamped");
339 assert!((window.utilization - 100.0).abs() < f64::EPSILON, "got {}", window.utilization);
340 }
341
342 #[test]
343 fn unified_utilization_clamps_to_the_percentage_range() {
344 let mut headers = HeaderMap::new();
345 headers
346 .insert("anthropic-ratelimit-unified-5h-utilization", HeaderValue::from_static("1.4"));
347 let window = unified_window(&headers, "5h").expect("window");
348 assert!((window.utilization - 100.0).abs() < f64::EPSILON, "got {}", window.utilization);
349 }
350
351 #[test]
356 fn a_200_without_unified_headers_maps_to_a_barless_snapshot() {
357 let snapshot = snapshot_from_unified_headers(&HeaderMap::new());
358 assert!(snapshot.five_hour.is_none());
359 assert!(snapshot.seven_day.is_none());
360 assert_eq!(snapshot.source, UsageSourceKind::Oauth);
361 }
362
363 const PROBE_RESPONSE_200: &str = concat!(
364 "HTTP/1.1 200 OK\r\n",
365 "content-type: application/json\r\n",
366 "anthropic-ratelimit-unified-5h-status: allowed\r\n",
367 "anthropic-ratelimit-unified-5h-utilization: 0.21\r\n",
368 "anthropic-ratelimit-unified-5h-reset: 1766664000\r\n",
369 "anthropic-ratelimit-unified-7d-utilization: 0.15\r\n",
370 "anthropic-ratelimit-unified-7d-reset: 1767268800\r\n",
371 "content-length: 2\r\n\r\n{}",
372 );
373
374 const PROBE_RESPONSE_429: &str =
375 "HTTP/1.1 429 Too Many Requests\r\nretry-after: 30\r\ncontent-length: 0\r\n\r\n";
376
377 const PROBE_RESPONSE_429_WITH_WINDOWS: &str = concat!(
380 "HTTP/1.1 429 Too Many Requests\r\n",
381 "retry-after: 30\r\n",
382 "anthropic-ratelimit-unified-status: rejected\r\n",
383 "anthropic-ratelimit-unified-5h-status: rejected\r\n",
384 "anthropic-ratelimit-unified-5h-utilization: 1.0\r\n",
385 "anthropic-ratelimit-unified-5h-reset: 1766664000\r\n",
386 "anthropic-ratelimit-unified-7d-status: allowed\r\n",
387 "anthropic-ratelimit-unified-7d-utilization: 0.42\r\n",
388 "anthropic-ratelimit-unified-7d-reset: 1767268800\r\n",
389 "content-length: 0\r\n\r\n",
390 );
391
392 const PROBE_RESPONSE_401: &str =
393 "HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\ncontent-length: 0\r\n\r\n";
394
395 fn serve_one(
400 listener: std::net::TcpListener,
401 response: &'static str,
402 ) -> std::sync::mpsc::Receiver<String> {
403 let (sender, receiver) = std::sync::mpsc::channel();
404 std::thread::spawn(move || {
405 use std::io::{Read, Write as _};
406 let Ok((mut sock, _)) = listener.accept() else { return };
407 let mut request = Vec::new();
408 let mut byte = [0u8; 1];
409 while !request.ends_with(b"\r\n\r\n") {
410 match sock.read(&mut byte) {
411 Ok(1) => request.push(byte[0]),
412 _ => return,
413 }
414 }
415 let length: usize = String::from_utf8_lossy(&request)
416 .lines()
417 .find_map(|line| line.strip_prefix("content-length: "))
418 .and_then(|value| value.trim().parse().ok())
419 .unwrap_or(0);
420 for _ in 0..length {
421 match sock.read(&mut byte) {
422 Ok(1) => request.push(byte[0]),
423 _ => return,
424 }
425 }
426 let _ = sock.write_all(response.as_bytes());
427 let _ = sock.shutdown(std::net::Shutdown::Both);
428 let _ = sender.send(String::from_utf8_lossy(&request).to_string());
429 });
430 receiver
431 }
432
433 #[tokio::test]
437 async fn the_messages_probe_posts_the_minimal_call_and_maps_the_headers() {
438 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
439 let addr = listener.local_addr().expect("addr");
440 let requests = serve_one(listener, PROBE_RESPONSE_200);
441 let client = reqwest::Client::builder().no_proxy().build().expect("client");
442 let headers = tokio::time::timeout(
443 Duration::from_secs(5),
444 messages_probe(
445 &client,
446 "claude-code/1.0.0",
447 Some(&format!("http://{addr}")),
448 "probe-token",
449 ),
450 )
451 .await
452 .expect("the probe returns")
453 .expect("probe");
454 let request =
455 requests.recv_timeout(Duration::from_secs(5)).expect("the server saw a request");
456 assert!(request.starts_with("POST /v1/messages "), "got {request:?}");
457 assert!(request.contains("anthropic-version: 2023-06-01"));
458 assert!(request.contains("authorization: Bearer probe-token"));
459 assert!(request.contains("user-agent: claude-code/1.0.0"));
460 assert!(request.contains("\"max_tokens\":1"));
461 assert!(request.contains("\"model\":\"claude-haiku-4-5\""));
462
463 let snapshot = snapshot_from_unified_headers(&headers);
464 let five_hour = snapshot.five_hour.expect("five hour window");
465 assert!((five_hour.utilization - 21.0).abs() < 1e-9, "got {}", five_hour.utilization);
466 let seven_day = snapshot.seven_day.expect("seven day window");
467 assert!((seven_day.utilization - 15.0).abs() < 1e-9, "got {}", seven_day.utilization);
468 }
469
470 #[tokio::test]
473 async fn a_429_is_a_rate_limited_error_with_the_retry_after() {
474 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
475 let addr = listener.local_addr().expect("addr");
476 let _requests = serve_one(listener, PROBE_RESPONSE_429);
477 let client = reqwest::Client::builder().no_proxy().build().expect("client");
478 let result = tokio::time::timeout(
479 Duration::from_secs(5),
480 messages_probe(&client, "claude-code/1.0.0", Some(&format!("http://{addr}")), "tok"),
481 )
482 .await
483 .expect("the probe returns");
484 assert!(
485 matches!(
486 result,
487 Err(OauthUsageError::RateLimited {
488 retry_after: Some(retry_after),
489 snapshot: None,
490 }) if retry_after == Duration::from_secs(30)
491 ),
492 "got {result:?}",
493 );
494 }
495
496 #[tokio::test]
501 async fn a_429_carrying_unified_headers_keeps_the_windows() {
502 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
503 let addr = listener.local_addr().expect("addr");
504 let _requests = serve_one(listener, PROBE_RESPONSE_429_WITH_WINDOWS);
505 let client = reqwest::Client::builder().no_proxy().build().expect("client");
506 let result = tokio::time::timeout(
507 Duration::from_secs(5),
508 messages_probe(&client, "claude-code/1.0.0", Some(&format!("http://{addr}")), "tok"),
509 )
510 .await
511 .expect("the probe returns");
512 let Err(OauthUsageError::RateLimited { retry_after, snapshot }) = result else {
513 panic!("a 429 must stay a rate-limited verdict, got {result:?}");
514 };
515 assert_eq!(
516 retry_after,
517 Some(Duration::from_secs(30)),
518 "the server hold-down survives the windows",
519 );
520 let snapshot = snapshot.expect("the 429's windows were dropped");
521 let five_hour = snapshot.five_hour.expect("five hour window");
522 assert!((five_hour.utilization - 100.0).abs() < f64::EPSILON);
523 assert_eq!(
524 five_hour.resets_at,
525 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_766_664_000)),
526 "which window is spent comes with when it lifts",
527 );
528 assert_eq!(five_hour.status.as_deref(), Some("rejected"), "the spent window's own verdict");
529 let seven_day = snapshot.seven_day.expect("seven day window");
530 assert!((seven_day.utilization - 42.0).abs() < 1e-9, "got {}", seven_day.utilization);
531 assert_eq!(
532 seven_day.resets_at,
533 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_268_800))
534 );
535 assert_eq!(seven_day.status.as_deref(), Some("allowed"));
536 }
537
538 #[tokio::test]
541 async fn a_401_is_an_unauthorized_error() {
542 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
543 let addr = listener.local_addr().expect("addr");
544 let _requests = serve_one(listener, PROBE_RESPONSE_401);
545 let client = reqwest::Client::builder().no_proxy().build().expect("client");
546 let result = tokio::time::timeout(
547 Duration::from_secs(5),
548 messages_probe(&client, "claude-code/1.0.0", Some(&format!("http://{addr}")), "tok"),
549 )
550 .await
551 .expect("the probe returns");
552 assert!(matches!(result, Err(OauthUsageError::Unauthorized(401))), "got {result:?}");
553 }
554
555 #[test]
558 fn the_complete_unified_family_maps_only_the_two_windows() {
559 let mut headers = unified_header_map();
560 for (name, value) in [
561 ("anthropic-ratelimit-unified-5h-status", "allowed"),
562 ("anthropic-ratelimit-unified-7d-status", "allowed"),
563 ("anthropic-ratelimit-unified-status", "allowed"),
564 ("anthropic-ratelimit-unified-reset", "1767268800"),
565 ("anthropic-ratelimit-unified-representative-claim", "5h"),
566 ("anthropic-ratelimit-unified-fallback", "0"),
567 ("anthropic-ratelimit-unified-overage-status", "active"),
568 ("anthropic-ratelimit-unified-overage-disabled-reason", ""),
569 ("anthropic-ratelimit-unified-grace-status", "none"),
570 ("anthropic-ratelimit-unified-grace-5h-utilization", "0"),
571 ("anthropic-ratelimit-unified-grace-7d-utilization", "0"),
572 ("anthropic-ratelimit-unified-upgrade-paths", "x"),
573 ] {
574 headers.insert(name, HeaderValue::from_static(value));
575 }
576 let snapshot = snapshot_from_unified_headers(&headers);
577 let five_hour = snapshot.five_hour.expect("five hour window");
578 assert!((five_hour.utilization - 53.0).abs() < 1e-9, "got {}", five_hour.utilization);
579 let seven_day = snapshot.seven_day.expect("seven day window");
580 assert!((seven_day.utilization - 6.0).abs() < 1e-9, "got {}", seven_day.utilization);
581 assert!(snapshot.seven_day_opus.is_none());
582 assert!(snapshot.seven_day_sonnet.is_none());
583 assert!(snapshot.extra_usage.is_none());
584 assert!(snapshot.spend.is_none());
585 }
586
587 #[test]
591 fn a_partial_window_maps_what_is_present() {
592 let mut utilization_only = HeaderMap::new();
593 utilization_only
594 .insert("anthropic-ratelimit-unified-5h-utilization", HeaderValue::from_static("0.2"));
595 let snapshot = snapshot_from_unified_headers(&utilization_only);
596 let five_hour = snapshot.five_hour.expect("window from utilization alone");
597 assert!((five_hour.utilization - 20.0).abs() < 1e-9, "got {}", five_hour.utilization);
598 assert_eq!(five_hour.resets_at, None);
599
600 let mut reset_only = HeaderMap::new();
601 reset_only
602 .insert("anthropic-ratelimit-unified-7d-reset", HeaderValue::from_static("1767268800"));
603 let snapshot = snapshot_from_unified_headers(&reset_only);
604 assert!(snapshot.seven_day.is_none(), "a reset without utilization maps no window");
605 }
606
607 #[tokio::test]
611 async fn a_host_ua_failure_is_a_ua_probe_error_not_a_network_failure() {
612 let backend = Anthropic;
613 let mut env = HashMap::new();
614 env.insert("CLAUDE_CODE_OAUTH_TOKEN".to_owned(), "setup-token".to_owned());
615 let account = AccountEnv { env: &env };
616 let result = backend.probe(&account, &FailingUaHost).await;
617 assert!(
618 matches!(result, Err(ProbeError::Fetch(OauthUsageError::UaProbe(_)))),
619 "got {result:?}",
620 );
621 }
622
623 #[tokio::test]
626 async fn a_tokenless_account_is_no_credentials_without_probing() {
627 let backend = Anthropic;
628 let account = AccountEnv { env: &HashMap::new() };
629 let result = backend.probe(&account, &EmptyHost).await;
630 assert!(matches!(result, Err(ProbeError::NoCredentials)), "got {result:?}");
631 }
632
633 struct FailingUaHost;
634
635 #[async_trait]
636 impl ProviderHost for FailingUaHost {
637 fn http_client(&self, _timeout: Duration) -> Result<reqwest::Client, String> {
638 reqwest::Client::builder().build().map_err(|e| e.to_string())
639 }
640
641 fn streaming_http_client(
642 &self,
643 _connect_timeout: Duration,
644 _idle_timeout: Duration,
645 ) -> Result<reqwest::Client, String> {
646 unreachable!("no probe streams a response")
647 }
648
649 async fn user_agent(&self) -> Result<String, String> {
650 Err("claude missing from PATH".to_owned())
651 }
652 }
653
654 struct EmptyHost;
655
656 #[async_trait]
657 impl ProviderHost for EmptyHost {
658 fn http_client(&self, _timeout: Duration) -> Result<reqwest::Client, String> {
659 unreachable!("the probe must not build a client for a missing credential")
660 }
661
662 fn streaming_http_client(
663 &self,
664 _connect_timeout: Duration,
665 _idle_timeout: Duration,
666 ) -> Result<reqwest::Client, String> {
667 unreachable!("the probe must not build a client for a missing credential")
668 }
669
670 async fn user_agent(&self) -> Result<String, String> {
671 unreachable!("the probe must not resolve a UA for a missing credential")
672 }
673 }
674}