ironflow_store/entities/provider_account.rs
1//! Provider Account entities: AI provider accounts, their usage windows and history.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use strum::{Display, EnumString};
8use uuid::Uuid;
9
10/// Prefix of the system secrets holding Provider Account credentials.
11///
12/// Keys under this prefix are hidden from the Secrets listing and refused by
13/// the Secrets API.
14pub const PROVIDER_ACCOUNT_SECRET_PREFIX: &str = "accounts/";
15
16/// Secret key holding the credential of the account `id`.
17///
18/// # Examples
19///
20/// ```
21/// use ironflow_store::entities::provider_account_secret_key;
22/// use uuid::Uuid;
23///
24/// let key = provider_account_secret_key(Uuid::nil());
25/// assert_eq!(key, "accounts/00000000-0000-0000-0000-000000000000/credential");
26/// ```
27pub fn provider_account_secret_key(id: Uuid) -> String {
28 format!("{PROVIDER_ACCOUNT_SECRET_PREFIX}{id}/credential")
29}
30
31/// Identifier of a Provider Account kind (e.g. `claude_subscription`).
32///
33/// Kinds are an open registry (custom kinds can be registered), so this is a
34/// newtype rather than a closed enum. It keeps a kind from being mixed up with
35/// an account name or any other string.
36///
37/// # Examples
38///
39/// ```
40/// use ironflow_store::entities::ProviderKind;
41///
42/// let kind = ProviderKind::new("claude_subscription");
43/// assert_eq!(kind.as_str(), "claude_subscription");
44/// assert_eq!(kind.to_string(), "claude_subscription");
45/// ```
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[serde(transparent)]
48pub struct ProviderKind(String);
49
50impl ProviderKind {
51 /// Wrap a kind identifier.
52 ///
53 /// # Examples
54 ///
55 /// ```
56 /// use ironflow_store::entities::ProviderKind;
57 ///
58 /// assert_eq!(ProviderKind::new("claude_subscription").as_str(), "claude_subscription");
59 /// ```
60 pub fn new(kind: impl Into<String>) -> Self {
61 Self(kind.into())
62 }
63
64 /// The kind identifier.
65 ///
66 /// # Examples
67 ///
68 /// ```
69 /// use ironflow_store::entities::ProviderKind;
70 ///
71 /// assert_eq!(ProviderKind::new("x").as_str(), "x");
72 /// ```
73 pub fn as_str(&self) -> &str {
74 &self.0
75 }
76}
77
78impl fmt::Display for ProviderKind {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.write_str(&self.0)
81 }
82}
83
84impl From<&str> for ProviderKind {
85 fn from(kind: &str) -> Self {
86 Self::new(kind)
87 }
88}
89
90impl From<String> for ProviderKind {
91 fn from(kind: String) -> Self {
92 Self(kind)
93 }
94}
95
96/// Status of a usage window, as reported by the provider.
97///
98/// # Examples
99///
100/// ```
101/// use ironflow_store::entities::AccountWindowStatus;
102///
103/// let status: AccountWindowStatus = "allowed_warning".parse().unwrap();
104/// assert_eq!(status, AccountWindowStatus::AllowedWarning);
105/// ```
106#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
107#[cfg_attr(feature = "store-postgres", derive(sqlx::Type))]
108#[cfg_attr(
109 feature = "store-postgres",
110 sqlx(type_name = "text", rename_all = "snake_case")
111)]
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
113#[serde(rename_all = "snake_case")]
114#[strum(serialize_all = "snake_case")]
115pub enum AccountWindowStatus {
116 /// Requests are allowed.
117 Allowed,
118 /// Requests are allowed, close to the limit.
119 AllowedWarning,
120 /// Requests are rejected until the window resets.
121 Rejected,
122}
123
124/// A persisted Provider Account.
125///
126/// The credential itself lives in the secret `secret_key`, never here.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct ProviderAccount {
129 /// Account ID (UUID v7).
130 pub id: Uuid,
131 /// Unique slug.
132 pub name: String,
133 /// Human-readable name.
134 pub display_name: String,
135 /// Account kind (e.g. `claude_subscription`).
136 pub kind: String,
137 /// Key of the secret holding the credential.
138 pub secret_key: String,
139 /// Whether the account may be selected.
140 pub enabled: bool,
141 /// Lower values are preferred by the `priority` strategy.
142 pub priority: i32,
143 /// Free-form tags.
144 pub tags: Vec<String>,
145 /// Maximum concurrent steps, `None` for unlimited.
146 pub max_concurrency: Option<u32>,
147 /// Utilization from which the account is shown as near its limit.
148 pub alert_threshold: f64,
149 /// When the credential expires.
150 pub expires_at: DateTime<Utc>,
151 /// Subscription plan (`pro`, `max`), informative.
152 pub plan: Option<String>,
153 /// When the provider last rejected the credential, `None` when valid.
154 pub auth_failed_at: Option<DateTime<Utc>>,
155 /// User who created the account.
156 pub created_by: Option<Uuid>,
157 /// Creation timestamp.
158 pub created_at: DateTime<Utc>,
159 /// Last update timestamp.
160 pub updated_at: DateTime<Utc>,
161}
162
163/// Request to create a Provider Account.
164///
165/// The caller generates `id` so the secret key can be built first.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct NewProviderAccount {
168 /// Account ID (UUID v7).
169 pub id: Uuid,
170 /// Unique slug.
171 pub name: String,
172 /// Human-readable name.
173 pub display_name: String,
174 /// Account kind.
175 pub kind: String,
176 /// Key of the secret holding the credential.
177 pub secret_key: String,
178 /// Whether the account may be selected.
179 pub enabled: bool,
180 /// Priority.
181 pub priority: i32,
182 /// Tags.
183 pub tags: Vec<String>,
184 /// Maximum concurrent steps.
185 pub max_concurrency: Option<u32>,
186 /// Alert threshold in `(0, 1]`.
187 pub alert_threshold: f64,
188 /// When the credential expires.
189 pub expires_at: DateTime<Utc>,
190 /// Subscription plan.
191 pub plan: Option<String>,
192 /// Creating user.
193 pub created_by: Option<Uuid>,
194}
195
196/// Partial update of a Provider Account. `None` fields are left unchanged.
197///
198/// # Examples
199///
200/// ```
201/// use ironflow_store::entities::ProviderAccountUpdate;
202///
203/// let update = ProviderAccountUpdate {
204/// enabled: Some(false),
205/// max_concurrency: Some(None),
206/// ..ProviderAccountUpdate::default()
207/// };
208/// assert_eq!(update.max_concurrency, Some(None));
209/// ```
210#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
211pub struct ProviderAccountUpdate {
212 /// New display name.
213 pub display_name: Option<String>,
214 /// Enable or disable.
215 pub enabled: Option<bool>,
216 /// New priority.
217 pub priority: Option<i32>,
218 /// New tags (replaces).
219 pub tags: Option<Vec<String>>,
220 /// New max concurrency; `Some(None)` clears it.
221 pub max_concurrency: Option<Option<u32>>,
222 /// New alert threshold.
223 pub alert_threshold: Option<f64>,
224 /// New expiry.
225 pub expires_at: Option<DateTime<Utc>>,
226 /// New plan; `Some(None)` clears it.
227 pub plan: Option<Option<String>>,
228 /// New auth failure mark; `Some(None)` clears it.
229 pub auth_failed_at: Option<Option<DateTime<Utc>>>,
230}
231
232/// Latest reading of one usage window of an account.
233#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235pub struct ProviderAccountWindow {
236 /// Account the window belongs to.
237 pub account_id: Uuid,
238 /// Window name (`five_hour`, `seven_day`).
239 pub window: String,
240 /// Fraction used, `0.0..=1.0`.
241 pub utilization: f64,
242 /// When the window resets.
243 pub resets_at: Option<DateTime<Utc>>,
244 /// Provider status.
245 pub status: AccountWindowStatus,
246 /// Model family the window applies to, `None` for every model.
247 pub model_scope: Option<String>,
248 /// When the window was observed.
249 pub observed_at: DateTime<Utc>,
250}
251
252/// One historical observation of a window.
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct ProviderAccountUsagePoint {
255 /// Observation ID.
256 pub id: Uuid,
257 /// Account the observation belongs to.
258 pub account_id: Uuid,
259 /// Window name.
260 pub window: String,
261 /// Fraction used.
262 pub utilization: f64,
263 /// When the window resets.
264 pub resets_at: Option<DateTime<Utc>>,
265 /// Provider status.
266 pub status: AccountWindowStatus,
267 /// Model scope.
268 pub model_scope: Option<String>,
269 /// When the window was observed.
270 pub observed_at: DateTime<Utc>,
271}
272
273/// A window observed during an invocation or a credential check.
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct NewAccountWindow {
276 /// Window name.
277 pub window: String,
278 /// Fraction used.
279 pub utilization: f64,
280 /// When the window resets.
281 pub resets_at: Option<DateTime<Utc>>,
282 /// Provider status.
283 pub status: AccountWindowStatus,
284 /// Model scope.
285 pub model_scope: Option<String>,
286 /// When the window was observed.
287 pub observed_at: DateTime<Utc>,
288}
289
290/// Observations to record for an account.
291///
292/// # Examples
293///
294/// ```
295/// use ironflow_store::entities::NewProviderAccountObservation;
296///
297/// let observation = NewProviderAccountObservation { windows: Vec::new(), auth_failed: true };
298/// assert!(observation.auth_failed);
299/// ```
300#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
301pub struct NewProviderAccountObservation {
302 /// Observed windows.
303 pub windows: Vec<NewAccountWindow>,
304 /// Whether the provider rejected the credential.
305 #[serde(default)]
306 pub auth_failed: bool,
307}
308
309/// An account eligible for selection, with its windows and load.
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311pub struct ProviderAccountCandidate {
312 /// The account.
313 pub account: ProviderAccount,
314 /// Its latest windows.
315 pub windows: Vec<ProviderAccountWindow>,
316 /// Steps currently running under it.
317 pub running_steps: u32,
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn secret_key_is_under_the_accounts_prefix() {
326 let id = Uuid::now_v7();
327 let key = provider_account_secret_key(id);
328 assert!(key.starts_with(PROVIDER_ACCOUNT_SECRET_PREFIX));
329 assert!(key.ends_with("/credential"));
330 assert!(key.contains(&id.to_string()));
331 }
332
333 #[test]
334 fn window_status_wire_format() {
335 assert_eq!(AccountWindowStatus::Rejected.to_string(), "rejected");
336 assert_eq!(
337 serde_json::to_string(&AccountWindowStatus::AllowedWarning).unwrap(),
338 "\"allowed_warning\""
339 );
340 assert!("nope".parse::<AccountWindowStatus>().is_err());
341 }
342
343 #[test]
344 fn observation_auth_failed_defaults_to_false() {
345 let observation: NewProviderAccountObservation =
346 serde_json::from_str(r#"{"windows":[]}"#).unwrap();
347 assert!(!observation.auth_failed);
348 }
349}