Skip to main content

forge_gateway/
codex.rs

1//! The Codex backend: a base-url proxy serving Anthropic's windowed
2//! `/api/oauth/usage` shape, authenticated by the account's flat
3//! `base_url` + `token` pair stamped onto `ANTHROPIC_BASE_URL` +
4//! `ANTHROPIC_AUTH_TOKEN`. The response always maps leniently - a
5//! proxy emits each window on its own and `{}` is the cold steady
6//! state, not a malformed response.
7
8use 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
19/// The Codex `[[accounts]] provider` token.
20pub 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
60/// The mapper the account's env earns, paired with the credential that
61/// earns it: the base-url pair always maps leniently, 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::Lenient(credential),
68        Err(missing) => Mapper::MissingBase(missing),
69    }
70}
71
72#[derive(Debug, PartialEq, Eq)]
73enum Mapper {
74    /// The credential bound to the lenient mapper: the arm holding
75    /// this cannot map strictly or read another credential.
76    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    /// The arm-routing pin: a base-url credential earns the lenient
97    /// mapper carrying that credential; a missing base earns the
98    /// Unmappable arm. Wiring the pair to a strict mapper, or probing
99    /// with a credential the env did not produce, cannot compile.
100    #[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    /// A host that cannot resolve the UA surfaces the UaProbe class -
122    /// a local exec problem, not a verdict about the endpoint - so the
123    /// callers' retry path engages.
124    #[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    /// A missing base url never reaches the network: the error
137    /// surfaces before a client or UA is built.
138    #[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}