Skip to main content

forge_primitives/
account.rs

1//! Account identity shapes shared across the forge crates.
2
3use serde::{Deserialize, Serialize};
4
5/// Which backend an account talks to, declared per `[[accounts]]` in
6/// `forge.toml`. This is the single source of truth for how the account
7/// is probed and how its usage renders; nothing infers either from
8/// `ANTHROPIC_BASE_URL`, which answers where the credential lives
9/// rather than what the backend bills for.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Provider {
13    /// Anthropic proper: a setup-token credential, plan windows on the
14    /// default host.
15    Anthropic,
16    /// A `claude-code-proxy` endpoint. Base-url like OpenRouter, but its
17    /// proxy serves the same windowed `/api/oauth/usage` body Anthropic
18    /// does, so it bills as a subscription.
19    Codex,
20    /// OpenRouter: pay-per-token, probed at `{base_url}/v1/key`. No
21    /// plan windows and no allowance, so its usage is spend over a
22    /// period rather than a percentage.
23    Openrouter,
24    /// Z.ai GLM coding plan: a credit-windowed subscription probed at
25    /// the monitor host derived from the base url's host root. Chat
26    /// rides the same env-override shape as the other base-url
27    /// providers.
28    Zai,
29}
30
31impl Provider {
32    /// Every accepted `provider` value, for the load error a missing or
33    /// unusable declaration produces.
34    pub const ACCEPTED: &'static str = "\"anthropic\", \"codex\", \"openrouter\", \"zai\"";
35
36    /// `true` when the account authenticates with a flat `token`
37    /// beside a flat `base_url` (a base-url provider). Both the probe
38    /// and preflight's repair copy branch on this rather than on the
39    /// provider itself. The billing model lives on the provider's
40    /// forge-gateway backend instead.
41    pub const fn uses_base_url(self) -> bool {
42        matches!(self, Self::Codex | Self::Openrouter | Self::Zai)
43    }
44}
45
46/// One account as the config load produces it. The gateway's account
47/// state is built from these, and the spawn path stamps the per-account
48/// env onto the child from here.
49pub struct LoadedAccount {
50    pub display_name: String,
51    /// Declared backend. Drives the usage probe and the billing shape.
52    /// See [`Provider`].
53    pub provider: Provider,
54    /// The flat `base_url` key. `None` for an Anthropic account: the
55    /// gateway constant is its upstream.
56    pub base_url: Option<String>,
57    /// The canonical model names the account serves. Drives the
58    /// gateway's selection walk.
59    pub models: Vec<String>,
60    /// Canonical name -> upstream slug, only where the spellings
61    /// differ. Keys are always members of [`Self::models`].
62    pub model_slugs: std::collections::HashMap<String, String>,
63    /// Canonical name -> the other names a request may arrive under for
64    /// that model. Keys are always members of [`Self::models`].
65    pub model_aliases: std::collections::HashMap<String, Vec<String>>,
66    /// Provider-behaviour extras (timeouts, context caps, fallback
67    /// switches) plus the derived credential and base-url variable
68    /// stamps the gateway and the child read.
69    pub env: std::collections::HashMap<String, String>,
70}
71
72/// Hand-written because the env map carries the account's credential
73/// stamps, which the derived output would print.
74impl std::fmt::Debug for LoadedAccount {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        let env: std::collections::BTreeMap<&str, &str> =
77            self.env.keys().map(|key| (key.as_str(), "[redacted]")).collect();
78        f.debug_struct("LoadedAccount")
79            .field("display_name", &self.display_name)
80            .field("provider", &self.provider)
81            .field("base_url", &self.base_url)
82            .field("models", &self.models)
83            .field("model_slugs", &self.model_slugs)
84            .field("model_aliases", &self.model_aliases)
85            .field("env", &env)
86            .finish()
87    }
88}
89
90/// How an account proves who it is, which is the only thing that
91/// changes what preflight tells you to do about a failed one. Derived
92/// from the provider plus the account's merged env; it carries the
93/// distinction and none of the secret.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum AccountAuth {
97    /// The account authenticates with a flat `token` beside a flat
98    /// `base_url` (the base-url providers). Repaired by editing the
99    /// account block, which needs a restart; the 60 s usage poll
100    /// recovers a transient bail.
101    BaseUrl,
102    /// The account's credential is a setup token, stamped onto
103    /// `CLAUDE_CODE_OAUTH_TOKEN` - the only credential an Anthropic
104    /// account has. Repaired by minting or re-minting the token on the
105    /// account block, which needs a restart; the 60 s usage poll
106    /// recovers a transient bail. An account whose block carries no
107    /// token classifies here too: the repair it needs is the same.
108    Token,
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// `ACCEPTED` is the load error's list of what the user may write,
116    /// and it is hand-maintained, so a new variant can ship without
117    /// appearing in it and leave the error telling people the wrong set.
118    #[test]
119    fn accepted_lists_every_variant() {
120        for (variant, token) in [
121            (Provider::Anthropic, "anthropic"),
122            (Provider::Codex, "codex"),
123            (Provider::Openrouter, "openrouter"),
124            (Provider::Zai, "zai"),
125        ] {
126            let serialized =
127                serde_json::to_string(&variant).expect("provider serializes to its toml token");
128            assert_eq!(
129                serialized,
130                format!("\"{token}\""),
131                "the toml spelling of {variant:?} is what a user writes",
132            );
133            assert!(
134                Provider::ACCEPTED.contains(token),
135                "ACCEPTED must name {token}, or the load error lists the wrong set",
136            );
137        }
138    }
139
140    #[test]
141    fn zai_parses_from_its_toml_token() {
142        let parsed: Provider = serde_json::from_str("\"zai\"").expect("zai parses");
143        assert_eq!(parsed, Provider::Zai);
144    }
145
146    #[test]
147    fn zai_rides_the_base_url_credential_shape() {
148        assert!(
149            Provider::Zai.uses_base_url(),
150            "the credential is an ANTHROPIC_AUTH_TOKEN beside an ANTHROPIC_BASE_URL",
151        );
152    }
153
154    /// The env map carries the account's credential stamps, so a
155    /// `{:?}` must never print a value from it.
156    #[test]
157    fn a_credential_never_reaches_the_account_debug_output() {
158        let account = LoadedAccount {
159            display_name: "Stargate".to_owned(),
160            provider: Provider::Anthropic,
161            base_url: None,
162            models: vec!["claude-sonnet-5".to_owned()],
163            model_slugs: std::collections::HashMap::new(),
164            model_aliases: std::collections::HashMap::new(),
165            env: std::collections::HashMap::from([(
166                "CLAUDE_CODE_OAUTH_TOKEN".to_owned(),
167                "account-supersecret".to_owned(),
168            )]),
169        };
170        let rendered = format!("{account:?}");
171        assert!(
172            !rendered.contains("account-supersecret"),
173            "the credential stamp leaked: {rendered}",
174        );
175        assert!(rendered.contains("Stargate"), "the account name stays readable: {rendered}");
176    }
177}