1use async_trait::async_trait;
9
10use forge_primitives::usage::oauth::OauthUsageError;
11use forge_primitives::usage::{UsageSnapshot, UsageSourceKind};
12
13use crate::helpers::{
14 BaseUrlCredential, MissingBase, OAUTH_TIMEOUT, anthropic_windowed_probe, base_url_credential,
15 snapshot_from_payload_lenient,
16};
17use crate::{AccountEnv, BillingModel, ProbeError, Provider, ProviderBackend, ProviderHost};
18
19pub struct Codex;
21
22#[async_trait]
23impl ProviderBackend for Codex {
24 fn token(&self) -> Provider {
25 Provider::Codex
26 }
27
28 fn billing(&self) -> BillingModel {
29 BillingModel::Windows
30 }
31
32 fn source(&self) -> UsageSourceKind {
33 UsageSourceKind::Oauth
34 }
35
36 async fn probe(
37 &self,
38 account: &AccountEnv<'_>,
39 host: &dyn ProviderHost,
40 ) -> Result<UsageSnapshot, ProbeError> {
41 match choose_mapper(base_url_credential(account.env)) {
42 Mapper::Lenient(credential) => {
43 let ua = host.user_agent().await.map_err(OauthUsageError::UaProbe)?;
44 let client = host.http_client(OAUTH_TIMEOUT).map_err(OauthUsageError::Network)?;
45 let payload = anthropic_windowed_probe(
46 &client,
47 &ua,
48 Some(&credential.base_url),
49 &credential.bearer,
50 )
51 .await
52 .map_err(ProbeError::Fetch)?;
53 Ok(snapshot_from_payload_lenient(payload))
54 }
55 Mapper::MissingBase(missing) => Err(ProbeError::Unmappable(missing.to_string())),
56 }
57 }
58}
59
60fn choose_mapper(credential: Result<BaseUrlCredential, MissingBase>) -> Mapper {
66 match credential {
67 Ok(credential) => Mapper::Lenient(credential),
68 Err(missing) => Mapper::MissingBase(missing),
69 }
70}
71
72#[derive(Debug, PartialEq, Eq)]
73enum Mapper {
74 Lenient(BaseUrlCredential),
77 MissingBase(MissingBase),
78}
79
80#[cfg(test)]
81mod tests {
82 use std::collections::HashMap;
83 use std::time::Duration;
84
85 use async_trait::async_trait;
86
87 use super::*;
88
89 fn env_with_base(base: &str) -> HashMap<String, String> {
90 let mut env = HashMap::new();
91 env.insert("ANTHROPIC_BASE_URL".to_owned(), base.to_owned());
92 env.insert("ANTHROPIC_AUTH_TOKEN".to_owned(), "sk-codex".to_owned());
93 env
94 }
95
96 #[test]
101 fn a_base_url_credential_earns_the_lenient_mapper_and_a_missing_base_the_error_arm() {
102 let credential =
103 base_url_credential(&env_with_base("http://localhost:18765")).expect("credential");
104 assert_eq!(
105 choose_mapper(Ok(credential.clone())),
106 Mapper::Lenient(credential),
107 "the lenient arm runs with the credential that earned it",
108 );
109 assert!(
110 matches!(choose_mapper(Err(MissingBase)), Mapper::MissingBase(_)),
111 "a missing base surfaces Unmappable, not a probe against a default host",
112 );
113 }
114
115 #[test]
116 fn codex_backend_is_windowed() {
117 assert_eq!(Codex.token(), Provider::Codex);
118 assert_eq!(Codex.billing(), BillingModel::Windows);
119 }
120
121 #[tokio::test]
125 async fn a_host_ua_failure_is_a_ua_probe_error_not_a_network_failure() {
126 let backend = Codex;
127 let env = env_with_base("http://localhost:18765");
128 let account = AccountEnv { env: &env };
129 let result = backend.probe(&account, &FailingUaHost).await;
130 assert!(
131 matches!(result, Err(ProbeError::Fetch(OauthUsageError::UaProbe(_)))),
132 "got {result:?}",
133 );
134 }
135
136 #[tokio::test]
139 async fn a_missing_base_is_unmappable_without_probing() {
140 let backend = Codex;
141 let account = AccountEnv { env: &HashMap::new() };
142 let result = backend.probe(&account, &UnreachableHost).await;
143 assert!(matches!(result, Err(ProbeError::Unmappable(_))), "got {result:?}");
144 }
145
146 struct FailingUaHost;
147
148 #[async_trait]
149 impl ProviderHost for FailingUaHost {
150 fn http_client(&self, _timeout: Duration) -> Result<reqwest::Client, String> {
151 reqwest::Client::builder().build().map_err(|e| e.to_string())
152 }
153
154 fn streaming_http_client(
155 &self,
156 _connect_timeout: Duration,
157 _idle_timeout: Duration,
158 ) -> Result<reqwest::Client, String> {
159 unreachable!("no probe streams a response")
160 }
161
162 async fn user_agent(&self) -> Result<String, String> {
163 Err("claude missing from PATH".to_owned())
164 }
165 }
166
167 struct UnreachableHost;
168
169 #[async_trait]
170 impl ProviderHost for UnreachableHost {
171 fn http_client(&self, _timeout: Duration) -> Result<reqwest::Client, String> {
172 unreachable!("the probe must not build a client for a missing base url")
173 }
174
175 fn streaming_http_client(
176 &self,
177 _connect_timeout: Duration,
178 _idle_timeout: Duration,
179 ) -> Result<reqwest::Client, String> {
180 unreachable!("the probe must not build a client for a missing base url")
181 }
182
183 async fn user_agent(&self) -> Result<String, String> {
184 unreachable!("the probe must not resolve a UA for a missing base url")
185 }
186 }
187}