Skip to main content

ironflow_api/entities/
provider_account.rs

1//! Provider Account request and response DTOs.
2//!
3//! No response carries the credential or the key of the secret holding it.
4//! The request DTOs redact the token in their `Debug` output.
5
6use std::fmt;
7
8use chrono::{DateTime, Utc};
9use ironflow_core::account::AccountFormField;
10use ironflow_store::entities::{
11    AccountWindowStatus, ProviderAccount, ProviderAccountUsagePoint, ProviderAccountWindow,
12};
13use serde::{Deserialize, Deserializer, Serialize};
14use uuid::Uuid;
15use validator::{Validate, ValidationError};
16
17/// Maximum length of an account name or tag.
18const MAX_SLUG_LEN: usize = 63;
19
20/// Check a slug: `^[a-z0-9][a-z0-9-]{0,62}$`.
21fn is_slug(value: &str) -> bool {
22    let mut chars = value.chars();
23    let Some(first) = chars.next() else {
24        return false;
25    };
26    value.len() <= MAX_SLUG_LEN
27        && (first.is_ascii_lowercase() || first.is_ascii_digit())
28        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
29}
30
31fn validate_slug(value: &str) -> Result<(), ValidationError> {
32    if is_slug(value) {
33        return Ok(());
34    }
35    let mut err = ValidationError::new("invalid_slug");
36    err.message = Some("must match ^[a-z0-9][a-z0-9-]{0,62}$".into());
37    Err(err)
38}
39
40fn validate_tags(tags: &[String]) -> Result<(), ValidationError> {
41    if tags.iter().all(|t| is_slug(t)) {
42        return Ok(());
43    }
44    let mut err = ValidationError::new("invalid_tag");
45    err.message = Some("every tag must match ^[a-z0-9][a-z0-9-]{0,62}$".into());
46    Err(err)
47}
48
49fn validate_threshold(threshold: f64) -> Result<(), ValidationError> {
50    if threshold > 0.0 && threshold <= 1.0 {
51        return Ok(());
52    }
53    let mut err = ValidationError::new("invalid_threshold");
54    err.message = Some("alert_threshold must be in (0, 1]".into());
55    Err(err)
56}
57
58/// Deserialize a present field (`null` included) as `Some`, so a missing
59/// field stays `None` and `null` becomes `Some(None)`.
60fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
61where
62    T: Deserialize<'de>,
63    D: Deserializer<'de>,
64{
65    T::deserialize(deserializer).map(Some)
66}
67
68/// Request body to add a Provider Account. Admin only.
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[derive(Deserialize, Validate)]
71pub struct CreateProviderAccountRequest {
72    /// Unique slug, immutable (`^[a-z0-9][a-z0-9-]{0,62}$`).
73    #[validate(custom(function = "validate_slug"))]
74    pub name: String,
75    /// Human-readable name, defaults to `name`.
76    #[validate(length(min = 1, max = 200))]
77    pub display_name: Option<String>,
78    /// Account kind (see `GET /provider-accounts/kinds`).
79    #[validate(length(min = 1, max = 64))]
80    pub kind: String,
81    /// Credential, write-only (e.g. a `claude setup-token` token).
82    #[validate(length(min = 1, max = 4096))]
83    pub token: String,
84    /// Whether the account may be selected (default `true`).
85    pub enabled: Option<bool>,
86    /// Priority, lower is preferred (default 100).
87    pub priority: Option<i32>,
88    /// Tags, each a slug.
89    #[validate(custom(function = "validate_tags"))]
90    pub tags: Option<Vec<String>>,
91    /// Maximum concurrent steps (>= 1).
92    #[validate(range(min = 1))]
93    pub max_concurrency: Option<u32>,
94    /// Utilization from which the account is shown as near its limit, in `(0, 1]` (default 0.8).
95    #[validate(custom(function = "validate_threshold"))]
96    pub alert_threshold: Option<f64>,
97    /// When the credential expires (default: now + 365 days).
98    pub expires_at: Option<DateTime<Utc>>,
99    /// Subscription plan, informative (`pro`, `max`).
100    #[validate(length(max = 64))]
101    pub plan: Option<String>,
102}
103
104impl fmt::Debug for CreateProviderAccountRequest {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.debug_struct("CreateProviderAccountRequest")
107            .field("name", &self.name)
108            .field("kind", &self.kind)
109            .field("token", &"[REDACTED]")
110            .finish_non_exhaustive()
111    }
112}
113
114/// Request body to update a Provider Account. All fields optional; `name`
115/// and `kind` are immutable.
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117#[derive(Default, Deserialize, Validate)]
118#[serde(deny_unknown_fields)]
119pub struct UpdateProviderAccountRequest {
120    /// New display name.
121    #[validate(length(min = 1, max = 200))]
122    pub display_name: Option<String>,
123    /// Enable or disable.
124    pub enabled: Option<bool>,
125    /// New priority.
126    pub priority: Option<i32>,
127    /// New tags (replaces the list).
128    #[validate(custom(function = "validate_tags"))]
129    pub tags: Option<Vec<String>>,
130    /// New max concurrency; `null` removes the limit.
131    #[serde(default, deserialize_with = "deserialize_some")]
132    #[cfg_attr(feature = "openapi", schema(value_type = Option<u32>))]
133    pub max_concurrency: Option<Option<u32>>,
134    /// New alert threshold in `(0, 1]`.
135    #[validate(custom(function = "validate_threshold"))]
136    pub alert_threshold: Option<f64>,
137    /// New expiry of the credential.
138    pub expires_at: Option<DateTime<Utc>>,
139    /// New plan; `null` clears it.
140    #[serde(default, deserialize_with = "deserialize_some")]
141    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
142    pub plan: Option<Option<String>>,
143    /// Replacement credential, write-only. Checked against the provider.
144    #[validate(length(min = 1, max = 4096))]
145    pub token: Option<String>,
146}
147
148impl UpdateProviderAccountRequest {
149    /// Check the fields `validator` cannot express on nested options.
150    ///
151    /// # Errors
152    ///
153    /// Returns a message when `max_concurrency` is `0` or `plan` is too long.
154    pub fn validate_nested(&self) -> Result<(), String> {
155        if self.max_concurrency == Some(Some(0)) {
156            return Err("max_concurrency must be >= 1".to_string());
157        }
158        if let Some(Some(plan)) = &self.plan
159            && plan.len() > 64
160        {
161            return Err("plan must be at most 64 characters".to_string());
162        }
163        Ok(())
164    }
165
166    /// Names of the fields present in the request, except the token.
167    pub fn changed_fields(&self) -> Vec<&'static str> {
168        [
169            ("display_name", self.display_name.is_some()),
170            ("enabled", self.enabled.is_some()),
171            ("priority", self.priority.is_some()),
172            ("tags", self.tags.is_some()),
173            ("max_concurrency", self.max_concurrency.is_some()),
174            ("alert_threshold", self.alert_threshold.is_some()),
175            ("expires_at", self.expires_at.is_some()),
176            ("plan", self.plan.is_some()),
177        ]
178        .into_iter()
179        .filter_map(|(name, present)| present.then_some(name))
180        .collect()
181    }
182}
183
184impl fmt::Debug for UpdateProviderAccountRequest {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        f.debug_struct("UpdateProviderAccountRequest")
187            .field("fields", &self.changed_fields())
188            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
189            .finish()
190    }
191}
192
193/// Derived state of an account, for display.
194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum AccountState {
198    /// Usable, below its alert threshold.
199    Ok,
200    /// A window is at or above the alert threshold.
201    NearLimit,
202    /// A window rejects requests until it resets.
203    Limited,
204    /// The provider rejected the credential.
205    TokenInvalid,
206    /// No window observed yet.
207    NeverUsed,
208}
209
210/// One usage window of an account.
211#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct AccountWindowResponse {
214    /// Window name (`five_hour`, `seven_day`).
215    pub window: String,
216    /// Fraction used, `0.0..=1.0`.
217    pub utilization: f64,
218    /// When the window resets.
219    pub resets_at: Option<DateTime<Utc>>,
220    /// Provider status.
221    pub status: AccountWindowStatus,
222    /// Model family the window applies to, `null` for every model.
223    pub model_scope: Option<String>,
224    /// When the window was observed.
225    pub observed_at: DateTime<Utc>,
226}
227
228impl From<ProviderAccountWindow> for AccountWindowResponse {
229    fn from(w: ProviderAccountWindow) -> Self {
230        Self {
231            window: w.window,
232            utilization: w.utilization,
233            resets_at: w.resets_at,
234            status: w.status,
235            model_scope: w.model_scope,
236            observed_at: w.observed_at,
237        }
238    }
239}
240
241/// One historical observation of a window.
242#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct AccountUsagePointResponse {
245    /// Window name.
246    pub window: String,
247    /// Fraction used.
248    pub utilization: f64,
249    /// When the window resets.
250    pub resets_at: Option<DateTime<Utc>>,
251    /// Provider status.
252    pub status: AccountWindowStatus,
253    /// Model scope.
254    pub model_scope: Option<String>,
255    /// When the window was observed.
256    pub observed_at: DateTime<Utc>,
257}
258
259impl From<ProviderAccountUsagePoint> for AccountUsagePointResponse {
260    fn from(p: ProviderAccountUsagePoint) -> Self {
261        Self {
262            window: p.window,
263            utilization: p.utilization,
264            resets_at: p.resets_at,
265            status: p.status,
266            model_scope: p.model_scope,
267            observed_at: p.observed_at,
268        }
269    }
270}
271
272/// A Provider Account, without its credential.
273#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct ProviderAccountResponse {
276    /// Account ID.
277    pub id: Uuid,
278    /// Unique slug.
279    pub name: String,
280    /// Human-readable name.
281    pub display_name: String,
282    /// Account kind.
283    pub kind: String,
284    /// Whether the account may be selected.
285    pub enabled: bool,
286    /// Priority, lower is preferred.
287    pub priority: i32,
288    /// Tags.
289    pub tags: Vec<String>,
290    /// Maximum concurrent steps, `null` for unlimited.
291    pub max_concurrency: Option<u32>,
292    /// Alert threshold.
293    pub alert_threshold: f64,
294    /// When the credential expires.
295    pub expires_at: DateTime<Utc>,
296    /// Subscription plan.
297    pub plan: Option<String>,
298    /// Creating user.
299    pub created_by: Option<Uuid>,
300    /// Creation timestamp.
301    pub created_at: DateTime<Utc>,
302    /// Last update timestamp.
303    pub updated_at: DateTime<Utc>,
304    /// When the provider last rejected the credential.
305    pub auth_failed_at: Option<DateTime<Utc>>,
306    /// Derived state.
307    pub state: AccountState,
308    /// Latest windows.
309    pub windows: Vec<AccountWindowResponse>,
310}
311
312impl ProviderAccountResponse {
313    /// Build the response from an account, its windows and its derived state.
314    pub fn new(
315        account: ProviderAccount,
316        windows: Vec<ProviderAccountWindow>,
317        state: AccountState,
318    ) -> Self {
319        Self {
320            id: account.id,
321            name: account.name,
322            display_name: account.display_name,
323            kind: account.kind,
324            enabled: account.enabled,
325            priority: account.priority,
326            tags: account.tags,
327            max_concurrency: account.max_concurrency,
328            alert_threshold: account.alert_threshold,
329            expires_at: account.expires_at,
330            plan: account.plan,
331            created_by: account.created_by,
332            created_at: account.created_at,
333            updated_at: account.updated_at,
334            auth_failed_at: account.auth_failed_at,
335            state,
336            windows: windows
337                .into_iter()
338                .map(AccountWindowResponse::from)
339                .collect(),
340        }
341    }
342}
343
344/// Current windows and history of an account.
345#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ProviderAccountUsageResponse {
348    /// Account ID.
349    pub account_id: Uuid,
350    /// Account name.
351    pub name: String,
352    /// Latest windows.
353    pub windows: Vec<AccountWindowResponse>,
354    /// Observations over the requested period, oldest first.
355    pub history: Vec<AccountUsagePointResponse>,
356}
357
358/// Outcome of a live credential test.
359#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361#[serde(rename_all = "snake_case")]
362pub enum AccountTestResult {
363    /// The provider accepted the credential.
364    Valid,
365    /// The credential is valid but rate limited.
366    Limited,
367    /// The provider rejected the credential.
368    Unauthorized,
369}
370
371/// Response of `POST /provider-accounts/{id}/test`.
372#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct ProviderAccountTestResponse {
375    /// Outcome.
376    pub result: AccountTestResult,
377    /// Windows reported by the provider.
378    pub windows: Vec<AccountWindowResponse>,
379}
380
381/// A form field of an account kind.
382#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct AccountFormFieldResponse {
385    /// Field identifier.
386    pub name: String,
387    /// Label.
388    pub label: String,
389    /// Whether the field is a secret (write-only).
390    pub secret: bool,
391    /// Help text.
392    pub help: String,
393}
394
395impl From<AccountFormField> for AccountFormFieldResponse {
396    fn from(field: AccountFormField) -> Self {
397        Self {
398            name: field.name.to_string(),
399            label: field.label.to_string(),
400            secret: field.secret,
401            help: field.help.to_string(),
402        }
403    }
404}
405
406/// A supported account kind.
407#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct AccountKindResponse {
410    /// Kind identifier.
411    pub id: String,
412    /// Human-readable name.
413    pub display_name: String,
414    /// Fields of the add form.
415    pub fields: Vec<AccountFormFieldResponse>,
416}
417
418/// Query of `GET /provider-accounts`.
419#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
420#[derive(Debug, Default, Deserialize)]
421pub struct ListProviderAccountsQuery {
422    /// Only accounts of this kind.
423    pub kind: Option<String>,
424    /// Page number (1-based).
425    pub page: Option<u32>,
426    /// Items per page (max 100).
427    pub per_page: Option<u32>,
428}
429
430/// Query of `GET /provider-accounts/{id}/usage`.
431#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
432#[derive(Debug, Default, Deserialize)]
433pub struct UsageQuery {
434    /// History depth in days, 1 to 90 (default 30).
435    pub days: Option<u32>,
436}
437
438impl UsageQuery {
439    /// Requested depth, defaulted and clamped to `1..=90`.
440    pub fn days(&self) -> u32 {
441        self.days.unwrap_or(30).clamp(1, 90)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use serde_json::{from_str, json};
448
449    use super::*;
450
451    const TOKEN: &str = "sk-ant-oat01-secret-token-value";
452
453    fn create_request(name: &str) -> CreateProviderAccountRequest {
454        from_str(&json!({"name": name, "kind": "claude_subscription", "token": TOKEN}).to_string())
455            .unwrap()
456    }
457
458    #[test]
459    fn slug_validation() {
460        assert!(is_slug("perso-max"));
461        assert!(is_slug("0"));
462        assert!(!is_slug(""));
463        assert!(!is_slug("-perso"));
464        assert!(!is_slug("Perso"));
465        assert!(!is_slug("perso_max"));
466        assert!(!is_slug(&"a".repeat(64)));
467        assert!(is_slug(&"a".repeat(63)));
468    }
469
470    #[test]
471    fn create_request_validates_name_and_tags() {
472        assert!(create_request("perso").validate().is_ok());
473        assert!(create_request("Bad Name").validate().is_err());
474        let mut req = create_request("perso");
475        req.tags = Some(vec!["ok".to_string(), "Not OK".to_string()]);
476        assert!(req.validate().is_err());
477        let mut req = create_request("perso");
478        req.alert_threshold = Some(0.0);
479        assert!(req.validate().is_err());
480        let mut req = create_request("perso");
481        req.max_concurrency = Some(0);
482        assert!(req.validate().is_err());
483    }
484
485    #[test]
486    fn request_debug_redacts_token() {
487        let req = create_request("perso");
488        assert!(!format!("{req:?}").contains(TOKEN));
489        let update = UpdateProviderAccountRequest {
490            token: Some(TOKEN.to_string()),
491            ..UpdateProviderAccountRequest::default()
492        };
493        assert!(!format!("{update:?}").contains(TOKEN));
494    }
495
496    #[test]
497    fn update_request_distinguishes_null_from_missing() {
498        let update: UpdateProviderAccountRequest =
499            from_str(r#"{"max_concurrency": null, "priority": 5}"#).unwrap();
500        assert_eq!(update.max_concurrency, Some(None));
501        assert_eq!(update.plan, None);
502        assert_eq!(update.changed_fields(), vec!["priority", "max_concurrency"]);
503
504        let zero: UpdateProviderAccountRequest = from_str(r#"{"max_concurrency": 0}"#).unwrap();
505        assert!(zero.validate_nested().is_err());
506    }
507
508    #[test]
509    fn update_request_rejects_name_change() {
510        let err = from_str::<UpdateProviderAccountRequest>(r#"{"name": "other"}"#);
511        assert!(err.is_err());
512    }
513
514    #[test]
515    fn usage_query_days_are_clamped() {
516        assert_eq!(UsageQuery::default().days(), 30);
517        assert_eq!(UsageQuery { days: Some(0) }.days(), 1);
518        assert_eq!(UsageQuery { days: Some(365) }.days(), 90);
519    }
520}