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}