1use 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
17const MAX_SLUG_LEN: usize = 63;
19
20fn 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
58fn 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[derive(Deserialize, Validate)]
71pub struct CreateProviderAccountRequest {
72 #[validate(custom(function = "validate_slug"))]
74 pub name: String,
75 #[validate(length(min = 1, max = 200))]
77 pub display_name: Option<String>,
78 #[validate(length(min = 1, max = 64))]
80 pub kind: String,
81 #[validate(length(min = 1, max = 4096))]
83 pub token: String,
84 pub enabled: Option<bool>,
86 pub priority: Option<i32>,
88 #[validate(custom(function = "validate_tags"))]
90 pub tags: Option<Vec<String>>,
91 #[validate(range(min = 1))]
93 pub max_concurrency: Option<u32>,
94 #[validate(custom(function = "validate_threshold"))]
96 pub alert_threshold: Option<f64>,
97 pub expires_at: Option<DateTime<Utc>>,
99 #[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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117#[derive(Default, Deserialize, Validate)]
118#[serde(deny_unknown_fields)]
119pub struct UpdateProviderAccountRequest {
120 #[validate(length(min = 1, max = 200))]
122 pub display_name: Option<String>,
123 pub enabled: Option<bool>,
125 pub priority: Option<i32>,
127 #[validate(custom(function = "validate_tags"))]
129 pub tags: Option<Vec<String>>,
130 #[serde(default, deserialize_with = "deserialize_some")]
132 #[cfg_attr(feature = "openapi", schema(value_type = Option<u32>))]
133 pub max_concurrency: Option<Option<u32>>,
134 #[validate(custom(function = "validate_threshold"))]
136 pub alert_threshold: Option<f64>,
137 pub expires_at: Option<DateTime<Utc>>,
139 #[serde(default, deserialize_with = "deserialize_some")]
141 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
142 pub plan: Option<Option<String>>,
143 #[validate(length(min = 1, max = 4096))]
145 pub token: Option<String>,
146}
147
148impl UpdateProviderAccountRequest {
149 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 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#[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 Ok,
200 NearLimit,
202 Limited,
204 TokenInvalid,
206 NeverUsed,
208}
209
210#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct AccountWindowResponse {
214 pub window: String,
216 pub utilization: f64,
218 pub resets_at: Option<DateTime<Utc>>,
220 pub status: AccountWindowStatus,
222 pub model_scope: Option<String>,
224 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct AccountUsagePointResponse {
245 pub window: String,
247 pub utilization: f64,
249 pub resets_at: Option<DateTime<Utc>>,
251 pub status: AccountWindowStatus,
253 pub model_scope: Option<String>,
255 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct ProviderAccountResponse {
276 pub id: Uuid,
278 pub name: String,
280 pub display_name: String,
282 pub kind: String,
284 pub enabled: bool,
286 pub priority: i32,
288 pub tags: Vec<String>,
290 pub max_concurrency: Option<u32>,
292 pub alert_threshold: f64,
294 pub expires_at: DateTime<Utc>,
296 pub plan: Option<String>,
298 pub created_by: Option<Uuid>,
300 pub created_at: DateTime<Utc>,
302 pub updated_at: DateTime<Utc>,
304 pub auth_failed_at: Option<DateTime<Utc>>,
306 pub state: AccountState,
308 pub windows: Vec<AccountWindowResponse>,
310}
311
312impl ProviderAccountResponse {
313 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ProviderAccountUsageResponse {
348 pub account_id: Uuid,
350 pub name: String,
352 pub windows: Vec<AccountWindowResponse>,
354 pub history: Vec<AccountUsagePointResponse>,
356}
357
358#[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 Valid,
365 Limited,
367 Unauthorized,
369}
370
371#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct ProviderAccountTestResponse {
375 pub result: AccountTestResult,
377 pub windows: Vec<AccountWindowResponse>,
379}
380
381#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct AccountFormFieldResponse {
385 pub name: String,
387 pub label: String,
389 pub secret: bool,
391 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct AccountKindResponse {
410 pub id: String,
412 pub display_name: String,
414 pub fields: Vec<AccountFormFieldResponse>,
416}
417
418#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
420#[derive(Debug, Default, Deserialize)]
421pub struct ListProviderAccountsQuery {
422 pub kind: Option<String>,
424 pub page: Option<u32>,
426 pub per_page: Option<u32>,
428}
429
430#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
432#[derive(Debug, Default, Deserialize)]
433pub struct UsageQuery {
434 pub days: Option<u32>,
436}
437
438impl UsageQuery {
439 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}