1use roder_api::catalog::{
2 REASONING_HIGH, REASONING_LOW, REASONING_MEDIUM, REASONING_XHIGH,
3 model_supports_reasoning_effort,
4};
5use roder_api::inference::{
6 ReasoningConfig, RuntimeProfile, SpeedPolicyDecision, SpeedPolicyPhase,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct RuntimeSpeedPolicyConfig {
11 pub enabled: bool,
12 pub orientation_reasoning: String,
13 pub execution_reasoning: String,
14 pub verification_reasoning: String,
15 pub recovery_reasoning: String,
16 pub ultracode_reasoning: String,
17}
18
19impl Default for RuntimeSpeedPolicyConfig {
20 fn default() -> Self {
21 Self {
22 enabled: true,
23 orientation_reasoning: REASONING_HIGH.to_string(),
24 execution_reasoning: REASONING_LOW.to_string(),
25 verification_reasoning: REASONING_HIGH.to_string(),
26 recovery_reasoning: REASONING_MEDIUM.to_string(),
27 ultracode_reasoning: REASONING_XHIGH.to_string(),
28 }
29 }
30}
31
32#[derive(Debug, Clone, Default)]
33pub(crate) struct SpeedPolicyState {
34 assistant_messages: u32,
35 tool_rounds: u32,
36 verification_required: bool,
37 failure_seen: bool,
38}
39
40impl SpeedPolicyState {
41 pub(crate) fn record_model_output(&mut self, assistant_message_seen: bool, tool_calls: usize) {
42 if assistant_message_seen {
43 self.assistant_messages = self.assistant_messages.saturating_add(1);
44 }
45 if tool_calls > 0 {
46 self.tool_rounds = self.tool_rounds.saturating_add(1);
47 }
48 }
49
50 pub(crate) fn record_verification_required(&mut self) {
51 self.verification_required = true;
52 }
53
54 pub(crate) fn record_failure(&mut self) {
55 self.failure_seen = true;
56 }
57
58 pub(crate) fn decision(
59 &self,
60 runtime_profile: RuntimeProfile,
61 model: &str,
62 config: &RuntimeSpeedPolicyConfig,
63 ) -> Option<SpeedPolicyDecision> {
64 if !config.enabled {
65 return None;
66 }
67 if runtime_profile != RuntimeProfile::Eval {
68 return None;
69 }
70
71 let phase = self.phase();
72 let desired_reasoning = config.reasoning_for_phase(phase).to_string();
73 let supported = model_supports_reasoning_effort(model, &desired_reasoning);
74 Some(SpeedPolicyDecision {
75 phase,
76 applied_reasoning: supported.then(|| desired_reasoning.clone()),
77 desired_reasoning,
78 supported,
79 })
80 }
81
82 pub(crate) fn phase(&self) -> SpeedPolicyPhase {
83 if self.failure_seen {
84 SpeedPolicyPhase::Recovery
85 } else if self.verification_required {
86 SpeedPolicyPhase::Verification
87 } else if self.assistant_messages == 0 && self.tool_rounds == 0 {
88 SpeedPolicyPhase::Orientation
89 } else {
90 SpeedPolicyPhase::Execution
91 }
92 }
93}
94
95impl RuntimeSpeedPolicyConfig {
96 fn reasoning_for_phase(&self, phase: SpeedPolicyPhase) -> &str {
97 match phase {
98 SpeedPolicyPhase::Orientation => &self.orientation_reasoning,
99 SpeedPolicyPhase::Execution => &self.execution_reasoning,
100 SpeedPolicyPhase::Verification => &self.verification_reasoning,
101 SpeedPolicyPhase::Recovery => &self.recovery_reasoning,
102 }
103 }
104}
105
106pub(crate) fn reasoning_from_decision(
107 decision: Option<&SpeedPolicyDecision>,
108 fallback: ReasoningConfig,
109) -> ReasoningConfig {
110 let Some(level) = decision.and_then(|decision| decision.applied_reasoning.as_deref()) else {
111 return fallback;
112 };
113 ReasoningConfig {
114 enabled: true,
115 level: Some(level.to_string()),
116 }
117}
118
119pub(crate) fn reasoning_for_supported_effort(
120 model: &str,
121 desired_reasoning: &str,
122 fallback: ReasoningConfig,
123) -> (ReasoningConfig, bool) {
124 if model_supports_reasoning_effort(model, desired_reasoning) {
125 (
126 ReasoningConfig {
127 enabled: true,
128 level: Some(desired_reasoning.to_string()),
129 },
130 true,
131 )
132 } else {
133 (fallback, false)
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use roder_api::catalog::{REASONING_NONE, REASONING_XHIGH};
141
142 #[test]
143 fn speed_policy_moves_high_to_low_to_high_and_recovery_medium() {
144 let mut state = SpeedPolicyState::default();
145 let config = RuntimeSpeedPolicyConfig::default();
146
147 let orientation = state
148 .decision(RuntimeProfile::Eval, "gpt-5.5", &config)
149 .unwrap();
150 assert_eq!(orientation.phase, SpeedPolicyPhase::Orientation);
151 assert_eq!(
152 orientation.applied_reasoning.as_deref(),
153 Some(REASONING_HIGH)
154 );
155
156 state.record_model_output(true, 1);
157 let execution = state
158 .decision(RuntimeProfile::Eval, "gpt-5.5", &config)
159 .unwrap();
160 assert_eq!(execution.phase, SpeedPolicyPhase::Execution);
161 assert_eq!(execution.applied_reasoning.as_deref(), Some(REASONING_LOW));
162
163 state.record_verification_required();
164 let verification = state
165 .decision(RuntimeProfile::Eval, "gpt-5.5", &config)
166 .unwrap();
167 assert_eq!(verification.phase, SpeedPolicyPhase::Verification);
168 assert_eq!(
169 verification.applied_reasoning.as_deref(),
170 Some(REASONING_HIGH)
171 );
172
173 state.record_failure();
174 let recovery = state
175 .decision(RuntimeProfile::Eval, "gpt-5.5", &config)
176 .unwrap();
177 assert_eq!(recovery.phase, SpeedPolicyPhase::Recovery);
178 assert_eq!(
179 recovery.applied_reasoning.as_deref(),
180 Some(REASONING_MEDIUM)
181 );
182 }
183
184 #[test]
185 fn unsupported_model_degrades_to_fallback_reasoning() {
186 let state = SpeedPolicyState::default();
187 let config = RuntimeSpeedPolicyConfig::default();
188 let decision = state
189 .decision(RuntimeProfile::Eval, "mock", &config)
190 .unwrap();
191 let fallback = ReasoningConfig {
192 enabled: true,
193 level: Some(REASONING_XHIGH.to_string()),
194 };
195
196 assert_eq!(decision.phase, SpeedPolicyPhase::Orientation);
197 assert_eq!(decision.desired_reasoning, REASONING_HIGH);
198 assert_eq!(decision.applied_reasoning, None);
199 assert!(!decision.supported);
200 assert_eq!(
201 reasoning_from_decision(Some(&decision), fallback.clone()),
202 fallback
203 );
204 }
205
206 #[test]
207 fn supported_effort_override_degrades_to_fallback_reasoning() {
208 let fallback = ReasoningConfig {
209 enabled: true,
210 level: Some(REASONING_MEDIUM.to_string()),
211 };
212
213 let (reasoning, supported) =
214 reasoning_for_supported_effort("mock", REASONING_XHIGH, fallback.clone());
215 assert!(!supported);
216 assert_eq!(reasoning, fallback);
217
218 let (reasoning, supported) =
219 reasoning_for_supported_effort("gpt-5.5", REASONING_XHIGH, fallback);
220 assert!(supported);
221 assert_eq!(reasoning.level.as_deref(), Some(REASONING_XHIGH));
222 }
223
224 #[test]
225 fn non_eval_profiles_do_not_apply_speed_policy() {
226 let state = SpeedPolicyState::default();
227 let config = RuntimeSpeedPolicyConfig::default();
228 assert_eq!(
229 state.decision(RuntimeProfile::Interactive, "gpt-5.5", &config),
230 None
231 );
232 assert_eq!(
233 reasoning_from_decision(
234 None,
235 ReasoningConfig {
236 enabled: false,
237 level: Some(REASONING_NONE.to_string())
238 }
239 ),
240 ReasoningConfig {
241 enabled: false,
242 level: Some(REASONING_NONE.to_string())
243 }
244 );
245 }
246}