1use super::*;
2
3#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
4#[serde(rename_all = "kebab-case")]
5pub enum RetryProfileName {
6 Balanced,
7 SameUpstream,
8 AggressiveFailover,
9 CostPrimary,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct ResolvedRetryLayerConfig {
14 pub max_attempts: u32,
15 pub backoff_ms: u64,
16 pub backoff_max_ms: u64,
17 pub jitter_ms: u64,
18 pub on_status: String,
19 pub on_class: Vec<String>,
20 pub strategy: RetryStrategy,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct ResolvedRetryConfig {
25 pub upstream: ResolvedRetryLayerConfig,
26 pub route: ResolvedRetryLayerConfig,
27 #[serde(default = "ReasoningGuardConfig::default_resolved")]
28 pub reasoning_guard: ResolvedReasoningGuardConfig,
29 pub allow_cross_station_before_first_output: bool,
31 pub never_on_status: String,
32 pub never_on_class: Vec<String>,
33 pub cloudflare_challenge_cooldown_secs: u64,
34 pub cloudflare_timeout_cooldown_secs: u64,
35 pub transport_cooldown_secs: u64,
36 pub cooldown_backoff_factor: u64,
37 pub cooldown_backoff_max_secs: u64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41#[serde(deny_unknown_fields)]
42pub struct RetryLayerConfig {
43 #[serde(default)]
44 pub max_attempts: Option<u32>,
45 #[serde(default)]
46 pub backoff_ms: Option<u64>,
47 #[serde(default)]
48 pub backoff_max_ms: Option<u64>,
49 #[serde(default)]
50 pub jitter_ms: Option<u64>,
51 #[serde(default)]
52 pub on_status: Option<String>,
53 #[serde(default)]
54 pub on_class: Option<Vec<String>>,
55 #[serde(default)]
56 pub strategy: Option<RetryStrategy>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct RetryConfig {
62 #[serde(default)]
65 pub profile: Option<RetryProfileName>,
66 #[serde(default)]
67 pub upstream: Option<RetryLayerConfig>,
68 #[serde(default)]
69 pub provider: Option<RetryLayerConfig>,
70 #[serde(default)]
71 pub reasoning_guard: Option<ReasoningGuardConfig>,
72 #[serde(default)]
75 pub allow_cross_station_before_first_output: Option<bool>,
76 #[serde(default)]
77 pub never_on_status: Option<String>,
78 #[serde(default)]
79 pub never_on_class: Option<Vec<String>>,
80 #[serde(default)]
81 pub cloudflare_challenge_cooldown_secs: Option<u64>,
82 #[serde(default)]
83 pub cloudflare_timeout_cooldown_secs: Option<u64>,
84 #[serde(default)]
85 pub transport_cooldown_secs: Option<u64>,
86 #[serde(default)]
89 pub cooldown_backoff_factor: Option<u64>,
90 #[serde(default)]
91 pub cooldown_backoff_max_secs: Option<u64>,
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
95#[serde(rename_all = "kebab-case")]
96pub enum ReasoningGuardAction {
97 Observe,
99 Block,
101 #[default]
103 Retry,
104}
105
106#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
107#[serde(rename_all = "kebab-case")]
108pub enum ReasoningGuardStreamMode {
109 Off,
111 Observe,
113 #[default]
115 StrictBuffer,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
119#[serde(deny_unknown_fields)]
120pub struct ReasoningGuardConfig {
121 #[serde(default)]
122 pub enabled: Option<bool>,
123 #[serde(default)]
124 pub reasoning_equals: Option<Vec<i64>>,
125 #[serde(default)]
126 pub paths: Option<Vec<String>>,
127 #[serde(default)]
128 pub action: Option<ReasoningGuardAction>,
129 #[serde(default)]
130 pub stream_mode: Option<ReasoningGuardStreamMode>,
131 #[serde(default)]
132 pub max_guard_retries: Option<u32>,
133 #[serde(default)]
134 pub log_matches: Option<bool>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct ResolvedReasoningGuardConfig {
139 pub enabled: bool,
140 pub reasoning_equals: Vec<i64>,
141 pub paths: Vec<String>,
142 pub action: ReasoningGuardAction,
143 pub stream_mode: ReasoningGuardStreamMode,
144 pub max_guard_retries: u32,
145 pub log_matches: bool,
146}
147
148#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
149#[serde(rename_all = "snake_case")]
150pub enum RetryStrategy {
151 #[default]
153 Failover,
154 SameUpstream,
156}
157
158impl Default for RetryConfig {
159 fn default() -> Self {
160 Self {
161 profile: Some(RetryProfileName::Balanced),
162 upstream: None,
163 provider: None,
164 reasoning_guard: None,
165 allow_cross_station_before_first_output: None,
166 never_on_status: None,
167 never_on_class: None,
168 cloudflare_challenge_cooldown_secs: None,
169 cloudflare_timeout_cooldown_secs: None,
170 transport_cooldown_secs: None,
171 cooldown_backoff_factor: None,
172 cooldown_backoff_max_secs: None,
173 }
174 }
175}
176
177impl RetryProfileName {
178 pub fn defaults(self) -> ResolvedRetryConfig {
179 match self {
180 RetryProfileName::Balanced => ResolvedRetryConfig {
181 upstream: ResolvedRetryLayerConfig {
182 max_attempts: 2,
183 backoff_ms: 200,
184 backoff_max_ms: 2_000,
185 jitter_ms: 100,
186 on_status: "429,500-502,504-528,530-599".to_string(),
187 on_class: vec![
188 "upstream_transport_error".to_string(),
189 "cloudflare_timeout".to_string(),
190 "cloudflare_challenge".to_string(),
191 "upstream_rate_limited".to_string(),
192 "upstream_overloaded".to_string(),
193 ],
194 strategy: RetryStrategy::SameUpstream,
195 },
196 route: ResolvedRetryLayerConfig {
197 max_attempts: 2,
198 backoff_ms: 0,
199 backoff_max_ms: 0,
200 jitter_ms: 0,
201 on_status: "401,403,404,408,429,500-599,524".to_string(),
202 on_class: vec![
203 "upstream_transport_error".to_string(),
204 "routing_mismatch_capability".to_string(),
205 "image_generation_missing_result".to_string(),
206 "upstream_rate_limited".to_string(),
207 "upstream_overloaded".to_string(),
208 ],
209 strategy: RetryStrategy::Failover,
210 },
211 reasoning_guard: ReasoningGuardConfig::default_resolved(),
212 allow_cross_station_before_first_output: false,
213 never_on_status: "413,415,422".to_string(),
214 never_on_class: vec!["client_error_non_retryable".to_string()],
215 cloudflare_challenge_cooldown_secs: 300,
216 cloudflare_timeout_cooldown_secs: 60,
217 transport_cooldown_secs: 30,
218 cooldown_backoff_factor: 1,
219 cooldown_backoff_max_secs: 600,
220 },
221 RetryProfileName::SameUpstream => ResolvedRetryConfig {
222 upstream: ResolvedRetryLayerConfig {
223 max_attempts: 3,
224 ..RetryProfileName::Balanced.defaults().upstream
225 },
226 route: ResolvedRetryLayerConfig {
227 max_attempts: 1,
228 ..RetryProfileName::Balanced.defaults().route
229 },
230 ..RetryProfileName::Balanced.defaults()
231 },
232 RetryProfileName::AggressiveFailover => ResolvedRetryConfig {
233 upstream: ResolvedRetryLayerConfig {
234 max_attempts: 2,
235 backoff_ms: 200,
236 backoff_max_ms: 2_500,
237 jitter_ms: 150,
238 on_status: "429,500-502,504-528,530-599".to_string(),
239 on_class: vec![
240 "upstream_transport_error".to_string(),
241 "cloudflare_timeout".to_string(),
242 "cloudflare_challenge".to_string(),
243 "upstream_rate_limited".to_string(),
244 "upstream_overloaded".to_string(),
245 ],
246 strategy: RetryStrategy::SameUpstream,
247 },
248 route: ResolvedRetryLayerConfig {
249 max_attempts: 3,
250 backoff_ms: 0,
251 backoff_max_ms: 0,
252 jitter_ms: 0,
253 on_status: "401,403,404,408,429,500-599,524".to_string(),
254 on_class: vec![
255 "upstream_transport_error".to_string(),
256 "routing_mismatch_capability".to_string(),
257 "upstream_rate_limited".to_string(),
258 "upstream_overloaded".to_string(),
259 ],
260 strategy: RetryStrategy::Failover,
261 },
262 allow_cross_station_before_first_output: true,
263 ..RetryProfileName::Balanced.defaults()
264 },
265 RetryProfileName::CostPrimary => ResolvedRetryConfig {
266 route: ResolvedRetryLayerConfig {
267 max_attempts: 2,
268 ..RetryProfileName::Balanced.defaults().route
269 },
270 allow_cross_station_before_first_output: true,
271 transport_cooldown_secs: 30,
272 cooldown_backoff_factor: 2,
273 cooldown_backoff_max_secs: 900,
274 ..RetryProfileName::Balanced.defaults()
275 },
276 }
277 }
278}
279
280impl ReasoningGuardConfig {
281 pub fn default_resolved() -> ResolvedReasoningGuardConfig {
282 ResolvedReasoningGuardConfig {
283 enabled: false,
284 reasoning_equals: vec![516, 1034, 1552],
285 paths: vec![
286 "/responses".to_string(),
287 "/v1/responses".to_string(),
288 "/chat/completions".to_string(),
289 "/v1/chat/completions".to_string(),
290 ],
291 action: ReasoningGuardAction::Retry,
292 stream_mode: ReasoningGuardStreamMode::StrictBuffer,
293 max_guard_retries: 1,
294 log_matches: true,
295 }
296 }
297
298 pub fn resolve(&self) -> ResolvedReasoningGuardConfig {
299 let mut out = Self::default_resolved();
300 if let Some(v) = self.enabled {
301 out.enabled = v;
302 }
303 if let Some(v) = self.reasoning_equals.as_ref() {
304 out.reasoning_equals = v.clone();
305 }
306 if let Some(v) = self.paths.as_ref() {
307 out.paths = v
308 .iter()
309 .map(|path| normalize_reasoning_guard_path(path))
310 .filter(|path| !path.is_empty())
311 .collect();
312 }
313 if let Some(v) = self.action {
314 out.action = v;
315 }
316 if let Some(v) = self.stream_mode {
317 out.stream_mode = v;
318 }
319 if let Some(v) = self.max_guard_retries {
320 out.max_guard_retries = v.min(8);
321 }
322 if let Some(v) = self.log_matches {
323 out.log_matches = v;
324 }
325 out
326 }
327}
328
329fn normalize_reasoning_guard_path(path: &str) -> String {
330 let trimmed = path.trim();
331 if trimmed.is_empty() {
332 return String::new();
333 }
334 let mut normalized = if trimmed.starts_with('/') {
335 trimmed.to_string()
336 } else {
337 format!("/{trimmed}")
338 };
339 while normalized.len() > 1 && normalized.ends_with('/') {
340 normalized.pop();
341 }
342 normalized
343}
344
345impl RetryConfig {
346 pub fn resolve(&self) -> ResolvedRetryConfig {
347 let mut out = self
348 .profile
349 .unwrap_or(RetryProfileName::Balanced)
350 .defaults();
351
352 if let Some(layer) = self.upstream.as_ref() {
353 if let Some(v) = layer.max_attempts {
354 out.upstream.max_attempts = v;
355 }
356 if let Some(v) = layer.backoff_ms {
357 out.upstream.backoff_ms = v;
358 }
359 if let Some(v) = layer.backoff_max_ms {
360 out.upstream.backoff_max_ms = v;
361 }
362 if let Some(v) = layer.jitter_ms {
363 out.upstream.jitter_ms = v;
364 }
365 if let Some(v) = layer.on_status.as_deref() {
366 out.upstream.on_status = v.to_string();
367 }
368 if let Some(v) = layer.on_class.as_ref() {
369 out.upstream.on_class = v.clone();
370 }
371 if let Some(v) = layer.strategy {
372 out.upstream.strategy = v;
373 }
374 }
375 if let Some(layer) = self.provider.as_ref() {
376 if let Some(v) = layer.max_attempts {
377 out.route.max_attempts = v;
378 }
379 if let Some(v) = layer.backoff_ms {
380 out.route.backoff_ms = v;
381 }
382 if let Some(v) = layer.backoff_max_ms {
383 out.route.backoff_max_ms = v;
384 }
385 if let Some(v) = layer.jitter_ms {
386 out.route.jitter_ms = v;
387 }
388 if let Some(v) = layer.on_status.as_deref() {
389 out.route.on_status = v.to_string();
390 }
391 if let Some(v) = layer.on_class.as_ref() {
392 out.route.on_class = v.clone();
393 }
394 if let Some(v) = layer.strategy {
395 out.route.strategy = v;
396 }
397 }
398 if let Some(v) = self.allow_cross_station_before_first_output {
399 out.allow_cross_station_before_first_output = v;
400 }
401 if let Some(v) = self.never_on_status.as_deref() {
402 out.never_on_status = v.to_string();
403 }
404 if let Some(v) = self.never_on_class.as_ref() {
405 out.never_on_class = v.clone();
406 }
407 if let Some(v) = self.reasoning_guard.as_ref() {
408 out.reasoning_guard = v.resolve();
409 }
410 if let Some(v) = self.cloudflare_challenge_cooldown_secs {
411 out.cloudflare_challenge_cooldown_secs = v;
412 }
413 if let Some(v) = self.cloudflare_timeout_cooldown_secs {
414 out.cloudflare_timeout_cooldown_secs = v;
415 }
416 if let Some(v) = self.transport_cooldown_secs {
417 out.transport_cooldown_secs = v;
418 }
419 if let Some(v) = self.cooldown_backoff_factor {
420 out.cooldown_backoff_factor = v;
421 }
422 if let Some(v) = self.cooldown_backoff_max_secs {
423 out.cooldown_backoff_max_secs = v;
424 }
425
426 out
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn reasoning_guard_defaults_are_disabled() {
436 let resolved = RetryConfig::default().resolve();
437
438 assert!(!resolved.reasoning_guard.enabled);
439 assert_eq!(
440 resolved.reasoning_guard.reasoning_equals,
441 vec![516, 1034, 1552]
442 );
443 assert_eq!(
444 resolved.reasoning_guard.stream_mode,
445 ReasoningGuardStreamMode::StrictBuffer
446 );
447 assert_eq!(resolved.reasoning_guard.action, ReasoningGuardAction::Retry);
448 assert_eq!(resolved.reasoning_guard.max_guard_retries, 1);
449 }
450
451 #[test]
452 fn resolved_retry_deserializes_legacy_payload_without_reasoning_guard() {
453 let resolved: ResolvedRetryConfig = serde_json::from_value(serde_json::json!({
454 "upstream": {
455 "max_attempts": 2,
456 "backoff_ms": 200,
457 "backoff_max_ms": 2000,
458 "jitter_ms": 100,
459 "on_status": "429,500-599,524",
460 "on_class": ["upstream_transport_error"],
461 "strategy": "same_upstream"
462 },
463 "route": {
464 "max_attempts": 2,
465 "backoff_ms": 0,
466 "backoff_max_ms": 0,
467 "jitter_ms": 0,
468 "on_status": "401,403,404,408,429,500-599,524",
469 "on_class": ["upstream_transport_error"],
470 "strategy": "failover"
471 },
472 "allow_cross_station_before_first_output": true,
473 "never_on_status": "413,415,422",
474 "never_on_class": ["client_error_non_retryable"],
475 "cloudflare_challenge_cooldown_secs": 300,
476 "cloudflare_timeout_cooldown_secs": 12,
477 "transport_cooldown_secs": 45,
478 "cooldown_backoff_factor": 3,
479 "cooldown_backoff_max_secs": 180
480 }))
481 .expect("legacy resolved retry payload should deserialize");
482
483 assert_eq!(
484 resolved.reasoning_guard,
485 ReasoningGuardConfig::default_resolved()
486 );
487 }
488
489 #[test]
490 fn reasoning_guard_toml_overrides_resolve() {
491 let cfg: RetryConfig = toml::from_str(
492 r#"
493profile = "balanced"
494
495[reasoning_guard]
496enabled = true
497reasoning_equals = [516, 777]
498paths = ["responses", "/v1/chat/completions/"]
499action = "block"
500stream_mode = "off"
501max_guard_retries = 3
502log_matches = false
503"#,
504 )
505 .expect("parse retry config");
506
507 let resolved = cfg.resolve();
508 assert!(resolved.reasoning_guard.enabled);
509 assert_eq!(resolved.reasoning_guard.reasoning_equals, vec![516, 777]);
510 assert_eq!(
511 resolved.reasoning_guard.paths,
512 vec!["/responses".to_string(), "/v1/chat/completions".to_string()]
513 );
514 assert_eq!(resolved.reasoning_guard.action, ReasoningGuardAction::Block);
515 assert_eq!(
516 resolved.reasoning_guard.stream_mode,
517 ReasoningGuardStreamMode::Off
518 );
519 assert_eq!(resolved.reasoning_guard.max_guard_retries, 3);
520 assert!(!resolved.reasoning_guard.log_matches);
521 }
522}