Skip to main content

claude_codes/io/
rate_limit.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::fmt;
3
4/// Current rate limit disposition.
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub enum RateLimitStatus {
7    /// Request is within limits.
8    Allowed,
9    /// Request is within limits but approaching the cap.
10    AllowedWarning,
11    /// Request was rejected due to rate limiting.
12    Rejected,
13    /// A status not yet known to this version of the crate.
14    Unknown(String),
15}
16
17impl RateLimitStatus {
18    pub fn as_str(&self) -> &str {
19        match self {
20            Self::Allowed => "allowed",
21            Self::AllowedWarning => "allowed_warning",
22            Self::Rejected => "rejected",
23            Self::Unknown(s) => s.as_str(),
24        }
25    }
26}
27
28impl fmt::Display for RateLimitStatus {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.write_str(self.as_str())
31    }
32}
33
34impl From<&str> for RateLimitStatus {
35    fn from(s: &str) -> Self {
36        match s {
37            "allowed" => Self::Allowed,
38            "allowed_warning" => Self::AllowedWarning,
39            "rejected" => Self::Rejected,
40            other => Self::Unknown(other.to_string()),
41        }
42    }
43}
44
45impl Serialize for RateLimitStatus {
46    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
47        serializer.serialize_str(self.as_str())
48    }
49}
50
51impl<'de> Deserialize<'de> for RateLimitStatus {
52    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
53        let s = String::deserialize(deserializer)?;
54        Ok(Self::from(s.as_str()))
55    }
56}
57
58/// The time window a rate limit applies to.
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
60pub enum RateLimitWindow {
61    /// Five-hour rolling window.
62    FiveHour,
63    /// Seven-day rolling window covering all models.
64    SevenDay,
65    /// Seven-day rolling window scoped to Opus usage.
66    SevenDayOpus,
67    /// Seven-day rolling window scoped to Sonnet usage.
68    SevenDaySonnet,
69    /// Seven-day rolling window for models included in overage billing.
70    SevenDayOverageIncluded,
71    /// Overage (extra usage / usage credits) window.
72    Overage,
73    /// A window type not yet known to this version of the crate.
74    Unknown(String),
75}
76
77impl RateLimitWindow {
78    pub fn as_str(&self) -> &str {
79        match self {
80            Self::FiveHour => "five_hour",
81            Self::SevenDay => "seven_day",
82            Self::SevenDayOpus => "seven_day_opus",
83            Self::SevenDaySonnet => "seven_day_sonnet",
84            Self::SevenDayOverageIncluded => "seven_day_overage_included",
85            Self::Overage => "overage",
86            Self::Unknown(s) => s.as_str(),
87        }
88    }
89}
90
91impl fmt::Display for RateLimitWindow {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str(self.as_str())
94    }
95}
96
97impl From<&str> for RateLimitWindow {
98    fn from(s: &str) -> Self {
99        match s {
100            "five_hour" => Self::FiveHour,
101            "seven_day" => Self::SevenDay,
102            "seven_day_opus" => Self::SevenDayOpus,
103            "seven_day_sonnet" => Self::SevenDaySonnet,
104            "seven_day_overage_included" => Self::SevenDayOverageIncluded,
105            "overage" => Self::Overage,
106            other => Self::Unknown(other.to_string()),
107        }
108    }
109}
110
111impl Serialize for RateLimitWindow {
112    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
113        serializer.serialize_str(self.as_str())
114    }
115}
116
117impl<'de> Deserialize<'de> for RateLimitWindow {
118    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
119        let s = String::deserialize(deserializer)?;
120        Ok(Self::from(s.as_str()))
121    }
122}
123
124/// Whether overage billing was accepted or rejected.
125#[derive(Debug, Clone, PartialEq, Eq, Hash)]
126pub enum OverageStatus {
127    /// Overage was accepted.
128    Allowed,
129    /// Overage was accepted but usage is approaching the cap.
130    AllowedWarning,
131    /// Overage was rejected.
132    Rejected,
133    /// A status not yet known to this version of the crate.
134    Unknown(String),
135}
136
137impl OverageStatus {
138    pub fn as_str(&self) -> &str {
139        match self {
140            Self::Allowed => "allowed",
141            Self::AllowedWarning => "allowed_warning",
142            Self::Rejected => "rejected",
143            Self::Unknown(s) => s.as_str(),
144        }
145    }
146}
147
148impl fmt::Display for OverageStatus {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(self.as_str())
151    }
152}
153
154impl From<&str> for OverageStatus {
155    fn from(s: &str) -> Self {
156        match s {
157            "allowed" => Self::Allowed,
158            "allowed_warning" => Self::AllowedWarning,
159            "rejected" => Self::Rejected,
160            other => Self::Unknown(other.to_string()),
161        }
162    }
163}
164
165impl Serialize for OverageStatus {
166    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
167        serializer.serialize_str(self.as_str())
168    }
169}
170
171impl<'de> Deserialize<'de> for OverageStatus {
172    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
173        let s = String::deserialize(deserializer)?;
174        Ok(Self::from(s.as_str()))
175    }
176}
177
178/// Which spend limit blocked a request when it is not the member's own cap
179/// ([`RateLimitInfo::limit_scope`]).
180#[derive(Debug, Clone, PartialEq, Eq, Hash)]
181pub enum RateLimitScope {
182    Service,
183    Channel,
184    /// A pooled group budget shared by the member's team is used up.
185    GroupPool,
186    /// A scope not yet known to this version of the crate.
187    Unknown(String),
188}
189
190impl RateLimitScope {
191    pub fn as_str(&self) -> &str {
192        match self {
193            Self::Service => "service",
194            Self::Channel => "channel",
195            Self::GroupPool => "group_pool",
196            Self::Unknown(s) => s.as_str(),
197        }
198    }
199}
200
201impl fmt::Display for RateLimitScope {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.write_str(self.as_str())
204    }
205}
206
207impl From<&str> for RateLimitScope {
208    fn from(s: &str) -> Self {
209        match s {
210            "service" => Self::Service,
211            "channel" => Self::Channel,
212            "group_pool" => Self::GroupPool,
213            other => Self::Unknown(other.to_string()),
214        }
215    }
216}
217
218impl Serialize for RateLimitScope {
219    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
220        serializer.serialize_str(self.as_str())
221    }
222}
223
224impl<'de> Deserialize<'de> for RateLimitScope {
225    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
226        let s = String::deserialize(deserializer)?;
227        Ok(Self::from(s.as_str()))
228    }
229}
230
231/// Why overage billing is disabled.
232#[derive(Debug, Clone, PartialEq, Eq, Hash)]
233pub enum OverageDisabledReason {
234    /// Overage has not been provisioned for the account.
235    OverageNotProvisioned,
236    /// Overage is disabled at the organization level.
237    OrgLevelDisabled,
238    /// Overage is disabled at the organization level until a later time.
239    OrgLevelDisabledUntil,
240    /// The account is out of credits.
241    OutOfCredits,
242    /// Overage is disabled for this seat tier.
243    SeatTierLevelDisabled,
244    /// Overage is disabled for this member.
245    MemberLevelDisabled,
246    /// The seat tier has a zero credit limit.
247    SeatTierZeroCreditLimit,
248    /// The member's group has a zero credit limit.
249    GroupZeroCreditLimit,
250    /// The member has a zero credit limit.
251    MemberZeroCreditLimit,
252    /// Overage is disabled at the organization service level.
253    OrgServiceLevelDisabled,
254    /// No overage limits are configured.
255    NoLimitsConfigured,
256    /// The server failed to fetch the overage configuration.
257    FetchError,
258    /// A reason not yet known to this version of the crate (including the
259    /// server's own literal `"unknown"` value).
260    Unknown(String),
261}
262
263impl OverageDisabledReason {
264    pub fn as_str(&self) -> &str {
265        match self {
266            Self::OverageNotProvisioned => "overage_not_provisioned",
267            Self::OrgLevelDisabled => "org_level_disabled",
268            Self::OrgLevelDisabledUntil => "org_level_disabled_until",
269            Self::OutOfCredits => "out_of_credits",
270            Self::SeatTierLevelDisabled => "seat_tier_level_disabled",
271            Self::MemberLevelDisabled => "member_level_disabled",
272            Self::SeatTierZeroCreditLimit => "seat_tier_zero_credit_limit",
273            Self::GroupZeroCreditLimit => "group_zero_credit_limit",
274            Self::MemberZeroCreditLimit => "member_zero_credit_limit",
275            Self::OrgServiceLevelDisabled => "org_service_level_disabled",
276            Self::NoLimitsConfigured => "no_limits_configured",
277            Self::FetchError => "fetch_error",
278            Self::Unknown(s) => s.as_str(),
279        }
280    }
281}
282
283impl fmt::Display for OverageDisabledReason {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        f.write_str(self.as_str())
286    }
287}
288
289impl From<&str> for OverageDisabledReason {
290    fn from(s: &str) -> Self {
291        match s {
292            "overage_not_provisioned" => Self::OverageNotProvisioned,
293            "org_level_disabled" => Self::OrgLevelDisabled,
294            "org_level_disabled_until" => Self::OrgLevelDisabledUntil,
295            "out_of_credits" => Self::OutOfCredits,
296            "seat_tier_level_disabled" => Self::SeatTierLevelDisabled,
297            "member_level_disabled" => Self::MemberLevelDisabled,
298            "seat_tier_zero_credit_limit" => Self::SeatTierZeroCreditLimit,
299            "group_zero_credit_limit" => Self::GroupZeroCreditLimit,
300            "member_zero_credit_limit" => Self::MemberZeroCreditLimit,
301            "org_service_level_disabled" => Self::OrgServiceLevelDisabled,
302            "no_limits_configured" => Self::NoLimitsConfigured,
303            "fetch_error" => Self::FetchError,
304            other => Self::Unknown(other.to_string()),
305        }
306    }
307}
308
309impl Serialize for OverageDisabledReason {
310    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
311        serializer.serialize_str(self.as_str())
312    }
313}
314
315impl<'de> Deserialize<'de> for OverageDisabledReason {
316    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
317        let s = String::deserialize(deserializer)?;
318        Ok(Self::from(s.as_str()))
319    }
320}
321
322/// Rate limit event from Claude CLI.
323///
324/// Sent periodically to inform consumers about current rate limit status,
325/// including overage eligibility and reset timing.
326///
327/// # Example JSON
328///
329/// ```json
330/// {
331///   "type": "rate_limit_event",
332///   "rate_limit_info": {
333///     "status": "allowed",
334///     "resetsAt": 1771390800,
335///     "rateLimitType": "five_hour",
336///     "overageStatus": "rejected",
337///     "overageDisabledReason": "org_level_disabled",
338///     "isUsingOverage": false
339///   },
340///   "uuid": "76258cfb-0dc8-4d4b-8682-77082b59c03f",
341///   "session_id": "1ae0af5b-89fa-4075-8156-d5d3702f6505"
342/// }
343/// ```
344///
345/// # Example
346///
347/// ```
348/// use claude_codes::ClaudeOutput;
349///
350/// let json = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1771390800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"abc","session_id":"def"}"#;
351/// let output: ClaudeOutput = serde_json::from_str(json).unwrap();
352///
353/// if let Some(evt) = output.as_rate_limit_event() {
354///     println!("Rate limit status: {}", evt.rate_limit_info.status);
355///     if let Some(resets_at) = evt.rate_limit_info.resets_at {
356///         println!("Resets at: {}", resets_at);
357///     }
358/// }
359/// ```
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct RateLimitEvent {
362    /// Rate limit status details
363    pub rate_limit_info: RateLimitInfo,
364    /// Session identifier
365    #[serde(alias = "sessionId")]
366    pub session_id: String,
367    /// Unique identifier for this message
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub uuid: Option<String>,
370}
371
372/// Rate limit status information.
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub struct RateLimitInfo {
375    /// Current rate limit status
376    pub status: RateLimitStatus,
377    /// Unix timestamp when the rate limit resets
378    #[serde(rename = "resetsAt", skip_serializing_if = "Option::is_none")]
379    pub resets_at: Option<u64>,
380    /// Type of rate limit window
381    #[serde(rename = "rateLimitType", skip_serializing_if = "Option::is_none")]
382    pub rate_limit_type: Option<RateLimitWindow>,
383    /// Utilization of the rate limit (0.0 to 1.0)
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub utilization: Option<f64>,
386    /// Per-window usage for the 5-hour, weekly, and overage-included weekly
387    /// subscription windows, read from the `anthropic-ratelimit-unified-*`
388    /// response headers. Unlike the top-level `status`/`utilization` (which
389    /// describe the currently limiting window), every window here is tracked
390    /// on each observation. Absent until the first response carrying these
391    /// headers, and always absent for API-key/Bedrock/Vertex sessions
392    /// (CLI 2.1.259+).
393    #[serde(rename = "unifiedWindows", skip_serializing_if = "Option::is_none")]
394    pub unified_windows: Option<UnifiedRateLimitWindows>,
395    /// Overage status (e.g., rejected, allowed)
396    #[serde(skip_serializing_if = "Option::is_none", rename = "overageStatus")]
397    pub overage_status: Option<OverageStatus>,
398    /// Unix timestamp when the overage window resets
399    #[serde(rename = "overageResetsAt", skip_serializing_if = "Option::is_none")]
400    pub overage_resets_at: Option<u64>,
401    /// Reason overage is disabled, if applicable
402    #[serde(
403        rename = "overageDisabledReason",
404        skip_serializing_if = "Option::is_none"
405    )]
406    pub overage_disabled_reason: Option<OverageDisabledReason>,
407    /// Whether overage billing is active for the current request
408    #[serde(rename = "isUsingOverage", skip_serializing_if = "Option::is_none")]
409    pub is_using_overage: Option<bool>,
410    /// Whether overage billing is in use for the account
411    #[serde(rename = "overageInUse", skip_serializing_if = "Option::is_none")]
412    pub overage_in_use: Option<bool>,
413    /// Utilization warning threshold that was crossed (0.0 to 1.0)
414    #[serde(rename = "surpassedThreshold", skip_serializing_if = "Option::is_none")]
415    pub surpassed_threshold: Option<f64>,
416    /// True while usage is paused for the organization, absent otherwise.
417    /// `overageDisabledReason` then reads as it does at the organization's
418    /// monthly cap, so this is what tells the two apart. Marked `@internal`
419    /// upstream (CLI 2.1.319+).
420    #[serde(
421        rename = "dailyCapPaused",
422        default,
423        skip_serializing_if = "Option::is_none"
424    )]
425    pub daily_cap_paused: Option<bool>,
426    /// Latched usage-limit grace signal, observed on responses to requests
427    /// that sent `anthropic-usage-limit: extended`. While set, an
428    /// `overageStatus` of allowed/allowed_warning means paid extra usage
429    /// covers the overflow; combine with `status` rather than reading this
430    /// alone as "still usable" (CLI 2.1.259+).
431    #[serde(
432        rename = "rateLimitGraceActive",
433        skip_serializing_if = "Option::is_none"
434    )]
435    pub rate_limit_grace_active: Option<bool>,
436    /// Monthly service spend-cap utilization (Claude-in-Slack surface)
437    #[serde(
438        rename = "overagePeriodMonthly",
439        skip_serializing_if = "Option::is_none"
440    )]
441    pub overage_period_monthly: Option<OveragePeriodUtilization>,
442    /// Per-channel spend-cap utilization (Claude-in-Slack surface)
443    #[serde(
444        rename = "overagePeriodChannel",
445        skip_serializing_if = "Option::is_none"
446    )]
447    pub overage_period_channel: Option<OveragePeriodUtilization>,
448    /// Which spend limit blocked the request when it is not the member's own
449    /// cap. Absent on a plain member denial and from older CLIs.
450    #[serde(rename = "limitScope", skip_serializing_if = "Option::is_none")]
451    pub limit_scope: Option<RateLimitScope>,
452    /// Error code attached when a request was refused outright
453    #[serde(rename = "errorCode", skip_serializing_if = "Option::is_none")]
454    pub error_code: Option<RateLimitErrorCode>,
455    /// Whether the user is able to purchase credits themselves
456    #[serde(
457        rename = "canUserPurchaseCredits",
458        skip_serializing_if = "Option::is_none"
459    )]
460    pub can_user_purchase_credits: Option<bool>,
461    /// Whether the account has a chargeable saved payment method
462    #[serde(
463        rename = "hasChargeableSavedPaymentMethod",
464        skip_serializing_if = "Option::is_none"
465    )]
466    pub has_chargeable_saved_payment_method: Option<bool>,
467    /// Limited-time usage-credit offer the account holds at this limit, for
468    /// the IDE extension's limit banner (CLI 2.1.304+, `@internal`). Present
469    /// only on a rejected frame from a session the VS Code extension owns.
470    #[serde(rename = "usageCreditOffer", skip_serializing_if = "Option::is_none")]
471    pub usage_credit_offer: Option<UsageCreditOffer>,
472    /// The IDE limit banner's offer to ask the member's admin for a higher
473    /// spend limit (CLI 2.1.307+, `@internal`). Present only on a rejected
474    /// frame from a VS Code extension session for a Team or Enterprise
475    /// member at their own spend limit.
476    #[serde(rename = "adminRequestOffer", skip_serializing_if = "Option::is_none")]
477    pub admin_request_offer: Option<AdminRequestOffer>,
478}
479
480/// An offer to ask the member's admin for a higher spend limit, from
481/// [`RateLimitInfo::admin_request_offer`].
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483pub struct AdminRequestOffer {
484    /// Where the request stands.
485    pub state: AdminRequestOfferState,
486    /// Banner button text.
487    pub label: String,
488    /// Banner text once the request is sent.
489    #[serde(rename = "doneLabel")]
490    pub done_label: String,
491    /// Whether the banner asks for confirmation before filing.
492    pub confirm: bool,
493    /// Text of the confirmation prompt.
494    #[serde(rename = "confirmPrompt")]
495    pub confirm_prompt: String,
496    /// Whether the last filing failed.
497    #[serde(
498        rename = "lastFileFailed",
499        default,
500        skip_serializing_if = "Option::is_none"
501    )]
502    pub last_file_failed: Option<bool>,
503    /// Counter that moves when a click could not be acted on.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub revision: Option<i64>,
506}
507
508/// State of an [`AdminRequestOffer`].
509#[derive(Debug, Clone, PartialEq, Eq, Hash)]
510pub enum AdminRequestOfferState {
511    /// The member can ask their admin.
512    CanAsk,
513    /// A request is already pending.
514    Pending,
515    /// The request is being sent.
516    Sending,
517    /// The request was sent.
518    Sent,
519    /// A state not yet known to this version of the crate.
520    Unknown(String),
521}
522
523impl AdminRequestOfferState {
524    pub fn as_str(&self) -> &str {
525        match self {
526            Self::CanAsk => "can_ask",
527            Self::Pending => "pending",
528            Self::Sending => "sending",
529            Self::Sent => "sent",
530            Self::Unknown(s) => s.as_str(),
531        }
532    }
533}
534
535impl fmt::Display for AdminRequestOfferState {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        f.write_str(self.as_str())
538    }
539}
540
541impl From<&str> for AdminRequestOfferState {
542    fn from(s: &str) -> Self {
543        match s {
544            "can_ask" => Self::CanAsk,
545            "pending" => Self::Pending,
546            "sending" => Self::Sending,
547            "sent" => Self::Sent,
548            other => Self::Unknown(other.to_string()),
549        }
550    }
551}
552
553impl Serialize for AdminRequestOfferState {
554    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
555        serializer.serialize_str(self.as_str())
556    }
557}
558
559impl<'de> Deserialize<'de> for AdminRequestOfferState {
560    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
561        let s = String::deserialize(deserializer)?;
562        Ok(Self::from(s.as_str()))
563    }
564}
565
566/// A limited-time offer on usage credits, from
567/// [`RateLimitInfo::usage_credit_offer`].
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub struct UsageCreditOffer {
570    /// Kind of offer: a steeper discount, or extra credit for the same price.
571    pub kind: UsageCreditOfferKind,
572    /// Headline percent that every credit pack meets or beats.
573    #[serde(rename = "headlinePct")]
574    pub headline_pct: i64,
575    /// Whole days left in the account's offer window.
576    #[serde(rename = "daysLeft")]
577    pub days_left: i64,
578    /// Ready-made banner text.
579    pub label: String,
580}
581
582/// Kind of a [`UsageCreditOffer`].
583#[derive(Debug, Clone, PartialEq, Eq, Hash)]
584pub enum UsageCreditOfferKind {
585    /// A steeper discount on credit packs.
586    PctOff,
587    /// Extra credit for the same price.
588    ExtraCredit,
589    /// A kind not yet known to this version of the crate.
590    Unknown(String),
591}
592
593impl UsageCreditOfferKind {
594    pub fn as_str(&self) -> &str {
595        match self {
596            Self::PctOff => "pct_off",
597            Self::ExtraCredit => "extra_credit",
598            Self::Unknown(s) => s.as_str(),
599        }
600    }
601}
602
603impl fmt::Display for UsageCreditOfferKind {
604    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
605        f.write_str(self.as_str())
606    }
607}
608
609impl From<&str> for UsageCreditOfferKind {
610    fn from(s: &str) -> Self {
611        match s {
612            "pct_off" => Self::PctOff,
613            "extra_credit" => Self::ExtraCredit,
614            other => Self::Unknown(other.to_string()),
615        }
616    }
617}
618
619impl Serialize for UsageCreditOfferKind {
620    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
621        serializer.serialize_str(self.as_str())
622    }
623}
624
625impl<'de> Deserialize<'de> for UsageCreditOfferKind {
626    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
627        let s = String::deserialize(deserializer)?;
628        Ok(Self::from(s.as_str()))
629    }
630}
631
632/// Spend-cap utilization for an overage billing period.
633#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
634pub struct OveragePeriodUtilization {
635    /// Fraction of the spend cap consumed (0.0 to 1.0)
636    pub utilization: f64,
637}
638
639/// Per-window subscription usage, from
640/// [`RateLimitInfo::unified_windows`] (CLI 2.1.259+). Each window is present
641/// only when the account state carries it.
642#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
643pub struct UnifiedRateLimitWindows {
644    /// The 5-hour rolling window.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub five_hour: Option<UnifiedWindowUsage>,
647    /// The 7-day rolling window.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub seven_day: Option<UnifiedWindowUsage>,
650    /// The overage-included 7-day window (per-model bucket; present only for
651    /// accounts whose responses carry that window).
652    #[serde(default, skip_serializing_if = "Option::is_none")]
653    pub seven_day_overage_included: Option<UnifiedWindowUsage>,
654}
655
656/// Usage for a single unified rate-limit window, carried in
657/// [`UnifiedRateLimitWindows`].
658#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
659pub struct UnifiedWindowUsage {
660    /// Fraction of the window used (usually 0.0–1.0; values above 1.0 occur
661    /// when usage legitimately runs past a window's cap).
662    pub utilization: f64,
663    /// Unix epoch seconds when this window resets.
664    #[serde(rename = "resetsAt")]
665    pub resets_at: u64,
666    /// Status the server sent for this window: `allowed_warning` once past a
667    /// warning threshold, `rejected` at or past its limit. A rejected window
668    /// alone does not mean requests are refused; the top-level
669    /// [`RateLimitInfo::status`] says whether they are. Absent when the server
670    /// sent none (CLI 2.1.310+).
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub status: Option<RateLimitStatus>,
673    /// Highest warning threshold passed, as a fraction of the window's limit
674    /// (1.0 once rejected). Absent when the server sent none (CLI 2.1.310+).
675    #[serde(
676        rename = "surpassedThreshold",
677        default,
678        skip_serializing_if = "Option::is_none"
679    )]
680    pub surpassed_threshold: Option<f64>,
681}
682
683/// Error code carried on a rate limit event when a request was refused.
684#[derive(Debug, Clone, PartialEq, Eq, Hash)]
685pub enum RateLimitErrorCode {
686    /// The request requires purchasing usage credits.
687    CreditsRequired,
688    /// An error code not yet known to this version of the crate.
689    Unknown(String),
690}
691
692impl RateLimitErrorCode {
693    pub fn as_str(&self) -> &str {
694        match self {
695            Self::CreditsRequired => "credits_required",
696            Self::Unknown(s) => s.as_str(),
697        }
698    }
699}
700
701impl fmt::Display for RateLimitErrorCode {
702    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
703        f.write_str(self.as_str())
704    }
705}
706
707impl From<&str> for RateLimitErrorCode {
708    fn from(s: &str) -> Self {
709        match s {
710            "credits_required" => Self::CreditsRequired,
711            other => Self::Unknown(other.to_string()),
712        }
713    }
714}
715
716impl Serialize for RateLimitErrorCode {
717    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
718        serializer.serialize_str(self.as_str())
719    }
720}
721
722impl<'de> Deserialize<'de> for RateLimitErrorCode {
723    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
724        let s = String::deserialize(deserializer)?;
725        Ok(Self::from(s.as_str()))
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::{
732        OverageDisabledReason, OveragePeriodUtilization, OverageStatus, RateLimitErrorCode,
733        RateLimitStatus, RateLimitWindow, UsageCreditOffer, UsageCreditOfferKind,
734    };
735    use crate::io::ClaudeOutput;
736
737    #[test]
738    fn test_deserialize_rate_limit_event() {
739        let json = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1771390800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"76258cfb-0dc8-4d4b-8682-77082b59c03f","session_id":"1ae0af5b-89fa-4075-8156-d5d3702f6505"}"#;
740
741        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
742        assert!(output.is_rate_limit_event());
743        assert_eq!(output.message_type(), "rate_limit_event");
744        assert_eq!(
745            output.session_id(),
746            Some("1ae0af5b-89fa-4075-8156-d5d3702f6505")
747        );
748
749        let evt = output.as_rate_limit_event().unwrap();
750        assert_eq!(evt.rate_limit_info.status, RateLimitStatus::Allowed);
751        assert_eq!(evt.rate_limit_info.resets_at, Some(1771390800));
752        assert_eq!(
753            evt.rate_limit_info.rate_limit_type,
754            Some(RateLimitWindow::FiveHour)
755        );
756        assert_eq!(evt.rate_limit_info.utilization, None);
757        assert_eq!(
758            evt.rate_limit_info.overage_status,
759            Some(OverageStatus::Rejected)
760        );
761        assert_eq!(
762            evt.rate_limit_info.overage_disabled_reason,
763            Some(OverageDisabledReason::OrgLevelDisabled)
764        );
765        assert_eq!(evt.rate_limit_info.is_using_overage, Some(false));
766        assert_eq!(
767            evt.uuid,
768            Some("76258cfb-0dc8-4d4b-8682-77082b59c03f".to_string())
769        );
770    }
771
772    #[test]
773    fn test_deserialize_rate_limit_event_minimal() {
774        let json = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"session_id":"abc"}"#;
775
776        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
777        let evt = output.as_rate_limit_event().unwrap();
778        assert_eq!(evt.rate_limit_info.overage_disabled_reason, None);
779        assert_eq!(evt.rate_limit_info.is_using_overage, None);
780        assert!(evt.uuid.is_none());
781    }
782
783    #[test]
784    fn test_deserialize_rate_limit_event_allowed_warning() {
785        let json = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":1700000000,"rateLimitType":"five_hour","utilization":0.85,"isUsingOverage":false},"uuid":"550e8400-e29b-41d4-a716-446655440000","session_id":"test-session-id"}"#;
786
787        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
788        let evt = output.as_rate_limit_event().unwrap();
789        assert_eq!(evt.rate_limit_info.status, RateLimitStatus::AllowedWarning);
790        assert_eq!(evt.rate_limit_info.utilization, Some(0.85));
791        assert_eq!(evt.rate_limit_info.overage_status, None);
792        assert_eq!(evt.rate_limit_info.overage_disabled_reason, None);
793        assert_eq!(evt.rate_limit_info.is_using_overage, Some(false));
794    }
795
796    #[test]
797    fn test_deserialize_rate_limit_event_no_resets_at() {
798        let json = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","isUsingOverage":false},"uuid":"4269273d-3b5e-40ae-9765-cb3c12284c44","session_id":"f9626cf7-4d88-4844-9bb1-cab96909fc7b"}"#;
799
800        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
801        let evt = output.as_rate_limit_event().unwrap();
802        assert_eq!(evt.rate_limit_info.status, RateLimitStatus::Allowed);
803        assert_eq!(evt.rate_limit_info.resets_at, None);
804        assert_eq!(evt.rate_limit_info.rate_limit_type, None);
805        assert_eq!(evt.rate_limit_info.is_using_overage, Some(false));
806    }
807
808    #[test]
809    fn test_deserialize_rate_limit_event_rejected() {
810        let json = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","resetsAt":1700003600,"rateLimitType":"seven_day","isUsingOverage":false,"overageStatus":"rejected","overageDisabledReason":"out_of_credits"},"uuid":"660e8400-e29b-41d4-a716-446655440001","session_id":"test-session-id"}"#;
811
812        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
813        let evt = output.as_rate_limit_event().unwrap();
814        assert_eq!(evt.rate_limit_info.status, RateLimitStatus::Rejected);
815        assert_eq!(
816            evt.rate_limit_info.rate_limit_type,
817            Some(RateLimitWindow::SevenDay)
818        );
819        assert_eq!(
820            evt.rate_limit_info.overage_status,
821            Some(OverageStatus::Rejected)
822        );
823        assert_eq!(
824            evt.rate_limit_info.overage_disabled_reason,
825            Some(OverageDisabledReason::OutOfCredits)
826        );
827    }
828
829    #[test]
830    fn test_deserialize_rate_limit_event_full_2_1_205() {
831        let json = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","resetsAt":1700003600,"rateLimitType":"seven_day_overage_included","utilization":0.97,"overageStatus":"allowed_warning","overageResetsAt":1700007200,"overageDisabledReason":"seat_tier_zero_credit_limit","isUsingOverage":true,"overageInUse":true,"surpassedThreshold":0.8,"overagePeriodMonthly":{"utilization":0.5},"overagePeriodChannel":{"utilization":0.25},"errorCode":"credits_required","canUserPurchaseCredits":true,"hasChargeableSavedPaymentMethod":false},"session_id":"abc"}"#;
832
833        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
834        let evt = output.as_rate_limit_event().unwrap();
835        let info = &evt.rate_limit_info;
836        assert_eq!(
837            info.rate_limit_type,
838            Some(RateLimitWindow::SevenDayOverageIncluded)
839        );
840        assert_eq!(info.overage_status, Some(OverageStatus::AllowedWarning));
841        assert_eq!(info.overage_resets_at, Some(1700007200));
842        assert_eq!(
843            info.overage_disabled_reason,
844            Some(OverageDisabledReason::SeatTierZeroCreditLimit)
845        );
846        assert_eq!(info.is_using_overage, Some(true));
847        assert_eq!(info.overage_in_use, Some(true));
848        assert_eq!(info.surpassed_threshold, Some(0.8));
849        assert_eq!(
850            info.overage_period_monthly,
851            Some(OveragePeriodUtilization { utilization: 0.5 })
852        );
853        assert_eq!(
854            info.overage_period_channel,
855            Some(OveragePeriodUtilization { utilization: 0.25 })
856        );
857        assert_eq!(info.error_code, Some(RateLimitErrorCode::CreditsRequired));
858        assert_eq!(info.can_user_purchase_credits, Some(true));
859        assert_eq!(info.has_chargeable_saved_payment_method, Some(false));
860
861        let round_trip = serde_json::to_value(output).unwrap();
862        let original: serde_json::Value = serde_json::from_str(json).unwrap();
863        assert_eq!(round_trip, original);
864    }
865
866    #[test]
867    fn test_rate_limit_usage_credit_offer_round_trip() {
868        let json = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","errorCode":"credits_required","usageCreditOffer":{"kind":"pct_off","headlinePct":20,"daysLeft":3,"label":"20% off usage credits for 3 more days"}},"session_id":"abc"}"#;
869
870        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
871        let evt = output.as_rate_limit_event().unwrap();
872        assert_eq!(
873            evt.rate_limit_info.usage_credit_offer,
874            Some(UsageCreditOffer {
875                kind: UsageCreditOfferKind::PctOff,
876                headline_pct: 20,
877                days_left: 3,
878                label: "20% off usage credits for 3 more days".to_string(),
879            })
880        );
881
882        let round_trip = serde_json::to_value(output).unwrap();
883        let original: serde_json::Value = serde_json::from_str(json).unwrap();
884        assert_eq!(round_trip, original);
885    }
886
887    #[test]
888    fn test_usage_credit_offer_kind_round_trip() {
889        for s in ["pct_off", "extra_credit", "future_kind"] {
890            let kind: UsageCreditOfferKind = serde_json::from_value(serde_json::json!(s)).unwrap();
891            assert_eq!(kind.as_str(), s);
892            assert_eq!(serde_json::to_value(&kind).unwrap(), serde_json::json!(s));
893        }
894        assert_eq!(
895            UsageCreditOfferKind::from("future_kind"),
896            UsageCreditOfferKind::Unknown("future_kind".to_string())
897        );
898    }
899
900    #[test]
901    fn test_rate_limit_window_round_trip() {
902        for s in [
903            "five_hour",
904            "seven_day",
905            "seven_day_opus",
906            "seven_day_sonnet",
907            "seven_day_overage_included",
908            "overage",
909            "some_future_window",
910        ] {
911            assert_eq!(RateLimitWindow::from(s).as_str(), s);
912        }
913    }
914
915    #[test]
916    fn test_overage_disabled_reason_round_trip() {
917        for s in [
918            "overage_not_provisioned",
919            "org_level_disabled",
920            "org_level_disabled_until",
921            "out_of_credits",
922            "seat_tier_level_disabled",
923            "member_level_disabled",
924            "seat_tier_zero_credit_limit",
925            "group_zero_credit_limit",
926            "member_zero_credit_limit",
927            "org_service_level_disabled",
928            "no_limits_configured",
929            "fetch_error",
930            "unknown",
931        ] {
932            assert_eq!(OverageDisabledReason::from(s).as_str(), s);
933        }
934    }
935}