Skip to main content

codex_helper_core/policy_actions/
model.rs

1use serde::{Deserialize, Serialize};
2
3use crate::provider_signals::{
4    ProviderSignal, ProviderSignalConfidence, ProviderSignalKind, ProviderSignalTarget,
5};
6use crate::runtime_identity::ProviderEndpointKey;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum PolicyActionKind {
11    Cooldown,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "snake_case")]
16pub enum PolicyActionOwner {
17    CodexHelper,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum PolicyActionRecoveryState {
23    #[default]
24    Active,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct PolicyAction {
29    pub id: String,
30    pub kind: PolicyActionKind,
31    pub owner: PolicyActionOwner,
32    pub provider_endpoint_key: ProviderEndpointKey,
33    pub source_signal: ProviderSignal,
34    pub reason: String,
35    pub confidence: ProviderSignalConfidence,
36    pub created_at_ms: u64,
37    pub expires_at_ms: u64,
38    #[serde(default)]
39    pub recovery_state: PolicyActionRecoveryState,
40    #[serde(default)]
41    pub generation: u64,
42}
43
44impl PolicyAction {
45    pub fn cooldown_from_signal(
46        signal: ProviderSignal,
47        created_at_ms: u64,
48        default_cooldown_secs: u64,
49        generation: u64,
50    ) -> Option<Self> {
51        if !signal.is_high_confidence_route_facing() {
52            return None;
53        }
54        if !matches!(
55            signal.kind,
56            ProviderSignalKind::Quota
57                | ProviderSignalKind::RateLimit
58                | ProviderSignalKind::Capacity
59                | ProviderSignalKind::Transport
60                | ProviderSignalKind::Balance
61        ) {
62            return None;
63        }
64        let provider_endpoint_key = match &signal.target {
65            ProviderSignalTarget::ProviderEndpoint {
66                provider_endpoint_key,
67            } => provider_endpoint_key.clone(),
68            ProviderSignalTarget::Provider { .. } | ProviderSignalTarget::Service { .. } => {
69                return None;
70            }
71        };
72        let cooldown_secs = signal.cooldown_horizon_secs().or_else(|| {
73            (matches!(
74                signal.kind,
75                ProviderSignalKind::Capacity | ProviderSignalKind::Transport
76            ) && default_cooldown_secs > 0)
77                .then_some(default_cooldown_secs)
78        })?;
79        if cooldown_secs == 0 {
80            return None;
81        }
82
83        let reason = signal
84            .reason
85            .clone()
86            .or_else(|| signal.error_class.clone())
87            .unwrap_or_else(|| format!("{:?}", signal.kind).to_ascii_lowercase());
88        Some(Self {
89            id: format!(
90                "codex-helper:{}:{}",
91                provider_endpoint_key.stable_key(),
92                created_at_ms
93            ),
94            kind: PolicyActionKind::Cooldown,
95            owner: PolicyActionOwner::CodexHelper,
96            provider_endpoint_key,
97            source_signal: signal.clone(),
98            reason,
99            confidence: signal.confidence,
100            created_at_ms,
101            expires_at_ms: created_at_ms.saturating_add(cooldown_secs.saturating_mul(1000)),
102            recovery_state: PolicyActionRecoveryState::Active,
103            generation,
104        })
105    }
106
107    pub fn is_active_at(&self, now_ms: u64) -> bool {
108        self.recovery_state == PolicyActionRecoveryState::Active && now_ms < self.expires_at_ms
109    }
110
111    pub fn remaining_secs_at(&self, now_ms: u64) -> Option<u64> {
112        self.is_active_at(now_ms)
113            .then(|| self.expires_at_ms.saturating_sub(now_ms).div_ceil(1000))
114    }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct PolicyActionProjection {
119    pub provider_endpoint_key: ProviderEndpointKey,
120    pub active_cooldown: bool,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub cooldown_remaining_secs: Option<u64>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub reason: Option<String>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub action_id: Option<String>,
127}
128
129impl PolicyActionProjection {
130    pub fn from_action(action: &PolicyAction, now_ms: u64) -> Option<Self> {
131        let cooldown_remaining_secs = action.remaining_secs_at(now_ms)?;
132        Some(Self {
133            provider_endpoint_key: action.provider_endpoint_key.clone(),
134            active_cooldown: matches!(action.kind, PolicyActionKind::Cooldown),
135            cooldown_remaining_secs: Some(cooldown_remaining_secs),
136            reason: Some(action.reason.clone()),
137            action_id: Some(action.id.clone()),
138        })
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::provider_signals::{ProviderSignalSource, ProviderSignalTarget};
146
147    fn quota_signal(reset_after_secs: Option<u64>) -> ProviderSignal {
148        let mut signal = ProviderSignal::high_confidence_route_facing(
149            ProviderSignalKind::Quota,
150            ProviderSignalSource::UpstreamResponse,
151            ProviderSignalTarget::ProviderEndpoint {
152                provider_endpoint_key: ProviderEndpointKey::new("codex", "monthly", "default"),
153            },
154            100,
155        );
156        signal.reset_after_secs = reset_after_secs;
157        signal.reason = Some("usage_limit_reached".to_string());
158        signal
159    }
160
161    #[test]
162    fn high_confidence_quota_signal_creates_owned_cooldown() {
163        let action = PolicyAction::cooldown_from_signal(quota_signal(Some(30)), 1_000, 0, 7)
164            .expect("cooldown action");
165
166        assert_eq!(action.owner, PolicyActionOwner::CodexHelper);
167        assert_eq!(action.expires_at_ms, 31_000);
168        assert_eq!(action.generation, 7);
169        assert!(action.is_active_at(30_999));
170        assert!(!action.is_active_at(31_000));
171    }
172
173    #[test]
174    fn quota_without_horizon_is_recorded_only() {
175        assert!(PolicyAction::cooldown_from_signal(quota_signal(None), 1_000, 0, 1).is_none());
176    }
177}