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, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
119#[serde(rename_all = "kebab-case")]
120pub enum ReasoningGuardRetryExhaustedAction {
121 #[default]
123 Pass,
124 Block,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
129#[serde(deny_unknown_fields)]
130pub struct ReasoningGuardConfig {
131 #[serde(default)]
132 pub enabled: Option<bool>,
133 #[serde(default)]
134 pub reasoning_equals: Option<Vec<i64>>,
135 #[serde(default)]
136 pub boundary_sequence_max_n: Option<u32>,
137 #[serde(default)]
138 pub paths: Option<Vec<String>>,
139 #[serde(default)]
140 pub action: Option<ReasoningGuardAction>,
141 #[serde(default)]
142 pub stream_mode: Option<ReasoningGuardStreamMode>,
143 #[serde(default)]
144 pub max_guard_retries: Option<u32>,
145 #[serde(default)]
146 pub on_retry_exhausted: Option<ReasoningGuardRetryExhaustedAction>,
147 #[serde(default)]
148 pub log_matches: Option<bool>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct ResolvedReasoningGuardConfig {
153 pub enabled: bool,
154 pub reasoning_equals: Vec<i64>,
155 #[serde(default = "default_reasoning_guard_boundary_sequence_max_n")]
156 pub boundary_sequence_max_n: u32,
157 pub paths: Vec<String>,
158 pub action: ReasoningGuardAction,
159 pub stream_mode: ReasoningGuardStreamMode,
160 pub max_guard_retries: u32,
161 #[serde(default)]
162 pub on_retry_exhausted: ReasoningGuardRetryExhaustedAction,
163 pub log_matches: bool,
164}
165
166fn default_reasoning_guard_boundary_sequence_max_n() -> u32 {
167 4
168}
169
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
171#[serde(rename_all = "snake_case")]
172pub enum RetryStrategy {
173 #[default]
175 Failover,
176 SameUpstream,
178}
179
180impl Default for RetryConfig {
181 fn default() -> Self {
182 Self {
183 profile: Some(RetryProfileName::Balanced),
184 upstream: None,
185 provider: None,
186 reasoning_guard: None,
187 allow_cross_station_before_first_output: None,
188 never_on_status: None,
189 never_on_class: None,
190 cloudflare_challenge_cooldown_secs: None,
191 cloudflare_timeout_cooldown_secs: None,
192 transport_cooldown_secs: None,
193 cooldown_backoff_factor: None,
194 cooldown_backoff_max_secs: None,
195 }
196 }
197}
198
199impl RetryProfileName {
200 pub fn defaults(self) -> ResolvedRetryConfig {
201 match self {
202 RetryProfileName::Balanced => ResolvedRetryConfig {
203 upstream: ResolvedRetryLayerConfig {
204 max_attempts: 2,
205 backoff_ms: 200,
206 backoff_max_ms: 2_000,
207 jitter_ms: 100,
208 on_status: "429,500-502,504-528,530-599".to_string(),
209 on_class: vec![
210 "upstream_transport_error".to_string(),
211 "cloudflare_timeout".to_string(),
212 "cloudflare_challenge".to_string(),
213 "upstream_rate_limited".to_string(),
214 "upstream_overloaded".to_string(),
215 ],
216 strategy: RetryStrategy::SameUpstream,
217 },
218 route: ResolvedRetryLayerConfig {
219 max_attempts: 2,
220 backoff_ms: 0,
221 backoff_max_ms: 0,
222 jitter_ms: 0,
223 on_status: "401,403,404,408,429,500-599,524".to_string(),
224 on_class: vec![
225 "upstream_transport_error".to_string(),
226 "routing_mismatch_capability".to_string(),
227 "image_generation_missing_result".to_string(),
228 "upstream_rate_limited".to_string(),
229 "upstream_overloaded".to_string(),
230 ],
231 strategy: RetryStrategy::Failover,
232 },
233 reasoning_guard: ReasoningGuardConfig::default_resolved(),
234 allow_cross_station_before_first_output: false,
235 never_on_status: "413,415,422".to_string(),
236 never_on_class: vec!["client_error_non_retryable".to_string()],
237 cloudflare_challenge_cooldown_secs: 300,
238 cloudflare_timeout_cooldown_secs: 60,
239 transport_cooldown_secs: 30,
240 cooldown_backoff_factor: 1,
241 cooldown_backoff_max_secs: 600,
242 },
243 RetryProfileName::SameUpstream => ResolvedRetryConfig {
244 upstream: ResolvedRetryLayerConfig {
245 max_attempts: 3,
246 ..RetryProfileName::Balanced.defaults().upstream
247 },
248 route: ResolvedRetryLayerConfig {
249 max_attempts: 1,
250 ..RetryProfileName::Balanced.defaults().route
251 },
252 ..RetryProfileName::Balanced.defaults()
253 },
254 RetryProfileName::AggressiveFailover => ResolvedRetryConfig {
255 upstream: ResolvedRetryLayerConfig {
256 max_attempts: 2,
257 backoff_ms: 200,
258 backoff_max_ms: 2_500,
259 jitter_ms: 150,
260 on_status: "429,500-502,504-528,530-599".to_string(),
261 on_class: vec![
262 "upstream_transport_error".to_string(),
263 "cloudflare_timeout".to_string(),
264 "cloudflare_challenge".to_string(),
265 "upstream_rate_limited".to_string(),
266 "upstream_overloaded".to_string(),
267 ],
268 strategy: RetryStrategy::SameUpstream,
269 },
270 route: ResolvedRetryLayerConfig {
271 max_attempts: 3,
272 backoff_ms: 0,
273 backoff_max_ms: 0,
274 jitter_ms: 0,
275 on_status: "401,403,404,408,429,500-599,524".to_string(),
276 on_class: vec![
277 "upstream_transport_error".to_string(),
278 "routing_mismatch_capability".to_string(),
279 "upstream_rate_limited".to_string(),
280 "upstream_overloaded".to_string(),
281 ],
282 strategy: RetryStrategy::Failover,
283 },
284 allow_cross_station_before_first_output: true,
285 ..RetryProfileName::Balanced.defaults()
286 },
287 RetryProfileName::CostPrimary => ResolvedRetryConfig {
288 route: ResolvedRetryLayerConfig {
289 max_attempts: 2,
290 ..RetryProfileName::Balanced.defaults().route
291 },
292 allow_cross_station_before_first_output: true,
293 transport_cooldown_secs: 30,
294 cooldown_backoff_factor: 2,
295 cooldown_backoff_max_secs: 900,
296 ..RetryProfileName::Balanced.defaults()
297 },
298 }
299 }
300}
301
302impl ReasoningGuardConfig {
303 pub fn default_resolved() -> ResolvedReasoningGuardConfig {
304 ResolvedReasoningGuardConfig {
305 enabled: false,
306 reasoning_equals: vec![516, 1034, 1552],
307 boundary_sequence_max_n: 4,
308 paths: vec![
309 "/responses".to_string(),
310 "/v1/responses".to_string(),
311 "/chat/completions".to_string(),
312 "/v1/chat/completions".to_string(),
313 ],
314 action: ReasoningGuardAction::Retry,
315 stream_mode: ReasoningGuardStreamMode::StrictBuffer,
316 max_guard_retries: 1,
317 on_retry_exhausted: ReasoningGuardRetryExhaustedAction::Pass,
318 log_matches: true,
319 }
320 }
321
322 pub fn resolve(&self) -> ResolvedReasoningGuardConfig {
323 let mut out = Self::default_resolved();
324 if let Some(v) = self.enabled {
325 out.enabled = v;
326 }
327 if let Some(v) = self.reasoning_equals.as_ref() {
328 out.reasoning_equals = v.clone();
329 }
330 if let Some(v) = self.boundary_sequence_max_n {
331 out.boundary_sequence_max_n = v.min(16);
332 }
333 if let Some(v) = self.paths.as_ref() {
334 out.paths = v
335 .iter()
336 .map(|path| normalize_reasoning_guard_path(path))
337 .filter(|path| !path.is_empty())
338 .collect();
339 }
340 if let Some(v) = self.action {
341 out.action = v;
342 }
343 if let Some(v) = self.stream_mode {
344 out.stream_mode = v;
345 }
346 if let Some(v) = self.max_guard_retries {
347 out.max_guard_retries = v.min(8);
348 }
349 if let Some(v) = self.on_retry_exhausted {
350 out.on_retry_exhausted = v;
351 }
352 if let Some(v) = self.log_matches {
353 out.log_matches = v;
354 }
355 out
356 }
357}
358
359fn normalize_reasoning_guard_path(path: &str) -> String {
360 let trimmed = path.trim();
361 if trimmed.is_empty() {
362 return String::new();
363 }
364 let mut normalized = if trimmed.starts_with('/') {
365 trimmed.to_string()
366 } else {
367 format!("/{trimmed}")
368 };
369 while normalized.len() > 1 && normalized.ends_with('/') {
370 normalized.pop();
371 }
372 normalized
373}
374
375impl RetryConfig {
376 pub fn resolve(&self) -> ResolvedRetryConfig {
377 let mut out = self
378 .profile
379 .unwrap_or(RetryProfileName::Balanced)
380 .defaults();
381
382 if let Some(layer) = self.upstream.as_ref() {
383 if let Some(v) = layer.max_attempts {
384 out.upstream.max_attempts = v;
385 }
386 if let Some(v) = layer.backoff_ms {
387 out.upstream.backoff_ms = v;
388 }
389 if let Some(v) = layer.backoff_max_ms {
390 out.upstream.backoff_max_ms = v;
391 }
392 if let Some(v) = layer.jitter_ms {
393 out.upstream.jitter_ms = v;
394 }
395 if let Some(v) = layer.on_status.as_deref() {
396 out.upstream.on_status = v.to_string();
397 }
398 if let Some(v) = layer.on_class.as_ref() {
399 out.upstream.on_class = v.clone();
400 }
401 if let Some(v) = layer.strategy {
402 out.upstream.strategy = v;
403 }
404 }
405 if let Some(layer) = self.provider.as_ref() {
406 if let Some(v) = layer.max_attempts {
407 out.route.max_attempts = v;
408 }
409 if let Some(v) = layer.backoff_ms {
410 out.route.backoff_ms = v;
411 }
412 if let Some(v) = layer.backoff_max_ms {
413 out.route.backoff_max_ms = v;
414 }
415 if let Some(v) = layer.jitter_ms {
416 out.route.jitter_ms = v;
417 }
418 if let Some(v) = layer.on_status.as_deref() {
419 out.route.on_status = v.to_string();
420 }
421 if let Some(v) = layer.on_class.as_ref() {
422 out.route.on_class = v.clone();
423 }
424 if let Some(v) = layer.strategy {
425 out.route.strategy = v;
426 }
427 }
428 if let Some(v) = self.allow_cross_station_before_first_output {
429 out.allow_cross_station_before_first_output = v;
430 }
431 if let Some(v) = self.never_on_status.as_deref() {
432 out.never_on_status = v.to_string();
433 }
434 if let Some(v) = self.never_on_class.as_ref() {
435 out.never_on_class = v.clone();
436 }
437 if let Some(v) = self.reasoning_guard.as_ref() {
438 out.reasoning_guard = v.resolve();
439 }
440 if let Some(v) = self.cloudflare_challenge_cooldown_secs {
441 out.cloudflare_challenge_cooldown_secs = v;
442 }
443 if let Some(v) = self.cloudflare_timeout_cooldown_secs {
444 out.cloudflare_timeout_cooldown_secs = v;
445 }
446 if let Some(v) = self.transport_cooldown_secs {
447 out.transport_cooldown_secs = v;
448 }
449 if let Some(v) = self.cooldown_backoff_factor {
450 out.cooldown_backoff_factor = v;
451 }
452 if let Some(v) = self.cooldown_backoff_max_secs {
453 out.cooldown_backoff_max_secs = v;
454 }
455
456 out
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn reasoning_guard_defaults_are_disabled() {
466 let resolved = RetryConfig::default().resolve();
467
468 assert!(!resolved.reasoning_guard.enabled);
469 assert_eq!(
470 resolved.reasoning_guard.reasoning_equals,
471 vec![516, 1034, 1552]
472 );
473 assert_eq!(resolved.reasoning_guard.boundary_sequence_max_n, 4);
474 assert_eq!(
475 resolved.reasoning_guard.stream_mode,
476 ReasoningGuardStreamMode::StrictBuffer
477 );
478 assert_eq!(resolved.reasoning_guard.action, ReasoningGuardAction::Retry);
479 assert_eq!(resolved.reasoning_guard.max_guard_retries, 1);
480 assert_eq!(
481 resolved.reasoning_guard.on_retry_exhausted,
482 ReasoningGuardRetryExhaustedAction::Pass
483 );
484 }
485
486 #[test]
487 fn resolved_retry_deserializes_legacy_payload_without_reasoning_guard() {
488 let resolved: ResolvedRetryConfig = serde_json::from_value(serde_json::json!({
489 "upstream": {
490 "max_attempts": 2,
491 "backoff_ms": 200,
492 "backoff_max_ms": 2000,
493 "jitter_ms": 100,
494 "on_status": "429,500-599,524",
495 "on_class": ["upstream_transport_error"],
496 "strategy": "same_upstream"
497 },
498 "route": {
499 "max_attempts": 2,
500 "backoff_ms": 0,
501 "backoff_max_ms": 0,
502 "jitter_ms": 0,
503 "on_status": "401,403,404,408,429,500-599,524",
504 "on_class": ["upstream_transport_error"],
505 "strategy": "failover"
506 },
507 "allow_cross_station_before_first_output": true,
508 "never_on_status": "413,415,422",
509 "never_on_class": ["client_error_non_retryable"],
510 "cloudflare_challenge_cooldown_secs": 300,
511 "cloudflare_timeout_cooldown_secs": 12,
512 "transport_cooldown_secs": 45,
513 "cooldown_backoff_factor": 3,
514 "cooldown_backoff_max_secs": 180
515 }))
516 .expect("legacy resolved retry payload should deserialize");
517
518 assert_eq!(
519 resolved.reasoning_guard,
520 ReasoningGuardConfig::default_resolved()
521 );
522 }
523
524 #[test]
525 fn resolved_retry_deserializes_legacy_reasoning_guard_without_boundary_sequence() {
526 let resolved: ResolvedRetryConfig = serde_json::from_value(serde_json::json!({
527 "upstream": {
528 "max_attempts": 2,
529 "backoff_ms": 200,
530 "backoff_max_ms": 2000,
531 "jitter_ms": 100,
532 "on_status": "429,500-599,524",
533 "on_class": ["upstream_transport_error"],
534 "strategy": "same_upstream"
535 },
536 "route": {
537 "max_attempts": 2,
538 "backoff_ms": 0,
539 "backoff_max_ms": 0,
540 "jitter_ms": 0,
541 "on_status": "401,403,404,408,429,500-599,524",
542 "on_class": ["upstream_transport_error"],
543 "strategy": "failover"
544 },
545 "reasoning_guard": {
546 "enabled": true,
547 "reasoning_equals": [516, 1034, 1552],
548 "paths": ["/responses", "/v1/responses"],
549 "action": "retry",
550 "stream_mode": "strict-buffer",
551 "max_guard_retries": 1,
552 "log_matches": true
553 },
554 "allow_cross_station_before_first_output": true,
555 "never_on_status": "413,415,422",
556 "never_on_class": ["client_error_non_retryable"],
557 "cloudflare_challenge_cooldown_secs": 300,
558 "cloudflare_timeout_cooldown_secs": 12,
559 "transport_cooldown_secs": 45,
560 "cooldown_backoff_factor": 3,
561 "cooldown_backoff_max_secs": 180
562 }))
563 .expect("legacy resolved retry reasoning guard should deserialize");
564
565 assert!(resolved.reasoning_guard.enabled);
566 assert_eq!(resolved.reasoning_guard.boundary_sequence_max_n, 4);
567 }
568
569 #[test]
570 fn reasoning_guard_toml_overrides_resolve() {
571 let cfg: RetryConfig = toml::from_str(
572 r#"
573profile = "balanced"
574
575[reasoning_guard]
576enabled = true
577reasoning_equals = [516, 777]
578boundary_sequence_max_n = 0
579paths = ["responses", "/v1/chat/completions/"]
580action = "block"
581stream_mode = "off"
582max_guard_retries = 3
583log_matches = false
584"#,
585 )
586 .expect("parse retry config");
587
588 let resolved = cfg.resolve();
589 assert!(resolved.reasoning_guard.enabled);
590 assert_eq!(resolved.reasoning_guard.reasoning_equals, vec![516, 777]);
591 assert_eq!(resolved.reasoning_guard.boundary_sequence_max_n, 0);
592 assert_eq!(
593 resolved.reasoning_guard.paths,
594 vec!["/responses".to_string(), "/v1/chat/completions".to_string()]
595 );
596 assert_eq!(resolved.reasoning_guard.action, ReasoningGuardAction::Block);
597 assert_eq!(
598 resolved.reasoning_guard.stream_mode,
599 ReasoningGuardStreamMode::Off
600 );
601 assert_eq!(resolved.reasoning_guard.max_guard_retries, 3);
602 assert!(!resolved.reasoning_guard.log_matches);
603 }
604}