1use crate::error::CloudflareError;
4
5pub const API_BASE: &str = "https://api.cloudflare.com/client/v4";
7
8pub const TOKEN_ENV: &str = "CLOUDFLARE_API_TOKEN";
10
11pub const ACCOUNT_ENV: &str = "CLOUDFLARE_ACCOUNT_ID";
13
14#[derive(Clone)]
20pub struct AccountCredentials {
21 pub account_id: String,
22 pub api_token: String,
23}
24
25impl std::fmt::Debug for AccountCredentials {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.debug_struct("AccountCredentials")
28 .field("account_id", &self.account_id)
29 .field("api_token", &"<redacted>")
30 .finish()
31 }
32}
33
34impl std::fmt::Display for AccountCredentials {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(f, "Cloudflare account {} (token redacted)", self.account_id)
37 }
38}
39
40impl AccountCredentials {
41 pub fn from_env() -> Result<Self, CloudflareError> {
45 let account_id = std::env::var(ACCOUNT_ENV).unwrap_or_default().trim().to_string();
46 if account_id.is_empty() {
47 return Err(CloudflareError::MissingEnv {
48 env_var: ACCOUNT_ENV,
49 hint: format!(
50 "export {ACCOUNT_ENV}=<your account id> - found under Workers & Pages → Overview → Account ID"
51 ),
52 });
53 }
54 let api_token = std::env::var(TOKEN_ENV).unwrap_or_default().trim().to_string();
55 if api_token.is_empty() {
56 return Err(CloudflareError::MissingEnv {
57 env_var: TOKEN_ENV,
58 hint: format!(
59 "export {TOKEN_ENV}=<scoped api token> - create a custom token with Account → Workers AI → Write (some dashboards label it Edit)"
60 ),
61 });
62 }
63 Ok(Self { account_id, api_token })
64 }
65
66 pub fn from_env_lenient() -> Option<Self> {
68 let account_id = std::env::var(ACCOUNT_ENV).unwrap_or_default().trim().to_string();
69 let api_token = std::env::var(TOKEN_ENV).unwrap_or_default().trim().to_string();
70 if account_id.is_empty() || api_token.is_empty() {
71 None
72 } else {
73 Some(Self { account_id, api_token })
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct AuthProvider {
83 pub account_id: String,
84}
85
86impl AuthProvider {
87 pub fn new(account_id: impl Into<String>) -> Self {
90 Self { account_id: account_id.into().trim().to_string() }
91 }
92
93 pub fn from_env_lenient() -> Option<Self> {
95 std::env::var(ACCOUNT_ENV).ok().filter(|v| !v.trim().is_empty()).map(Self::new)
96 }
97
98 pub fn base_url(&self) -> String {
101 format!("{API_BASE}/accounts/{}/ai/v1", self.account_id)
102 }
103
104 pub fn models_url(&self) -> String {
106 format!(
107 "{API_BASE}/accounts/{}/ai/models/search?format=openrouter&per_page=1000",
108 self.account_id
109 )
110 }
111
112 pub fn verify_url(&self) -> String {
114 format!("{API_BASE}/user/tokens/verify")
115 }
116
117 pub fn run_url(&self, model: &str) -> String {
120 format!("{API_BASE}/accounts/{}/ai/run/{}", self.account_id, model)
121 }
122
123 pub fn cache_slug(&self) -> String {
125 use std::fmt::Write;
126 let mut hash: u64 = 0xcbf29ce484222325;
128 for byte in self.account_id.as_bytes() {
129 hash ^= u64::from(*byte);
130 hash = hash.wrapping_mul(0x100000001b3);
131 }
132 let mut slug = String::new();
133 let _ = write!(slug, "{hash:016x}");
134 slug
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn endpoints_are_stable() {
144 let provider = AuthProvider::new("00000000000000000000000000000000");
146 assert_eq!(
147 provider.base_url(),
148 "https://api.cloudflare.com/client/v4/accounts/00000000000000000000000000000000/ai/v1"
149 );
150 assert_eq!(
151 provider.models_url(),
152 "https://api.cloudflare.com/client/v4/accounts/00000000000000000000000000000000/ai/models/search?format=openrouter&per_page=1000"
153 );
154 assert_eq!(provider.verify_url(), "https://api.cloudflare.com/client/v4/user/tokens/verify");
155 assert_eq!(
156 provider.run_url("@cf/zai-org/glm-5.3-flash"),
157 "https://api.cloudflare.com/client/v4/accounts/00000000000000000000000000000000/ai/run/@cf/zai-org/glm-5.3-flash"
158 );
159 }
160
161 #[test]
162 fn cache_slug_is_hex_and_stable() {
163 let provider = AuthProvider::new("test-account");
164 let slug = provider.cache_slug();
165 assert_eq!(slug.len(), 16);
166 assert!(slug.chars().all(|c| c.is_ascii_hexdigit()));
167 assert_eq!(slug, AuthProvider::new("test-account").cache_slug());
168 assert_ne!(slug, AuthProvider::new("other-account").cache_slug());
169 }
170
171 #[test]
172 fn credentials_redact_token() {
173 let credentials = AccountCredentials { account_id: "acct".to_string(), api_token: "cfut_SECRET".to_string() };
174 let debug = format!("{credentials:?}");
175 assert!(!debug.contains("cfut_SECRET"), "Debug must redact the token");
176 let display = format!("{credentials}");
177 assert!(!display.contains("cfut_SECRET"), "Display must redact the token");
178 }
179}