1use serde_json::{Map, Value};
6
7use forge_primitives::{
8 ApiRetryError, RateLimitStatus, RuntimeSessionState, SettingsParseErrorUpdate,
9};
10
11fn record(value: &Value) -> Option<&Map<String, Value>> {
14 value.as_object()
15}
16
17fn number_field(record: &Map<String, Value>, keys: &[&str]) -> Option<f64> {
18 for key in keys {
19 if let Some(v) = record.get(*key).and_then(Value::as_f64)
20 && v.is_finite()
21 {
22 return Some(v);
23 }
24 }
25 None
26}
27
28fn parse_enum<T: serde::de::DeserializeOwned>(value: Option<&Value>) -> Option<T> {
34 serde_json::from_value(value?.clone()).ok()
35}
36
37fn parse_rate_limit_status(value: Option<&Value>) -> Option<RateLimitStatus> {
38 parse_enum(value)
39}
40
41pub fn parse_runtime_session_state(value: Option<&Value>) -> Option<RuntimeSessionState> {
42 parse_enum(value)
43}
44
45#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
55fn parse_clamped_u64(message: &Map<String, Value>, keys: &[&str]) -> Option<u64> {
56 let v = number_field(message, keys)?;
57 if v < 0.0 || v >= u64::MAX as f64 {
58 tracing::debug!(
59 target: crate::logging::targets::BRIDGE_LIFECYCLE,
60 raw = v,
61 keys = ?keys,
62 "dropping numeric field - value outside u64 range",
63 );
64 return None;
65 }
66 Some(v as u64)
67}
68
69#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
73fn parse_clamped_u16_optional(message: &Map<String, Value>, keys: &[&str]) -> Option<u16> {
74 let v = number_field(message, keys)?;
75 if v < 0.0 || v > f64::from(u16::MAX) {
76 tracing::debug!(
79 target: crate::logging::targets::BRIDGE_LIFECYCLE,
80 raw = v,
81 keys = ?keys,
82 "dropping status field - value outside u16 range",
83 );
84 return None;
85 }
86 Some(v as u16)
87}
88
89fn parse_api_retry_error(value: Option<&Value>) -> ApiRetryError {
90 match value.and_then(Value::as_str) {
91 Some("authentication_failed") => ApiRetryError::AuthenticationFailed,
92 Some("billing_error") => ApiRetryError::BillingError,
93 Some("rate_limit") => ApiRetryError::RateLimit,
94 Some("invalid_request") => ApiRetryError::InvalidRequest,
95 Some("server_error") => ApiRetryError::ServerError,
96 Some("max_output_tokens") => ApiRetryError::MaxOutputTokens,
97 _ => ApiRetryError::Unknown,
98 }
99}
100
101pub fn build_rate_limit_update(
105 rate_limit_info: Option<&Value>,
106) -> Option<forge_primitives::RateLimitUpdate> {
107 let info = rate_limit_info.and_then(record)?;
108 let status = parse_rate_limit_status(info.get("status"))?;
109
110 Some(forge_primitives::RateLimitUpdate {
111 status,
112 resets_at: number_field(info, &["resetsAt"]),
113 utilization: number_field(info, &["utilization"]),
114 rate_limit_type: info
115 .get("rateLimitType")
116 .and_then(Value::as_str)
117 .filter(|s| !s.is_empty())
118 .map(str::to_owned),
119 overage_status: parse_rate_limit_status(info.get("overageStatus")),
120 overage_resets_at: number_field(info, &["overageResetsAt"]),
121 overage_disabled_reason: info
122 .get("overageDisabledReason")
123 .and_then(Value::as_str)
124 .filter(|s| !s.is_empty())
125 .map(str::to_owned),
126 is_using_overage: info.get("isUsingOverage").and_then(Value::as_bool),
127 surpassed_threshold: number_field(info, &["surpassedThreshold"]),
128 })
129}
130
131pub fn build_api_retry_update(
142 message: &Map<String, Value>,
143) -> Option<forge_primitives::ApiRetryUpdate> {
144 let attempt = parse_clamped_u64(message, &["attempt"])?;
145 let max_retries = parse_clamped_u64(message, &["max_retries", "maxRetries"])?;
146 let retry_delay_ms = parse_clamped_u64(message, &["retry_delay_ms", "retryDelayMs"])?;
147 let error_status = parse_clamped_u16_optional(message, &["error_status", "errorStatus"]);
148 let error = parse_api_retry_error(message.get("error"));
149
150 Some(forge_primitives::ApiRetryUpdate {
151 attempt,
152 max_retries,
153 retry_delay_ms,
154 error_status,
155 error,
156 })
157}
158
159fn normalize_settings_parse_error(value: &Value) -> Option<SettingsParseErrorUpdate> {
161 let r = record(value)?;
162 let message = r.get("message").and_then(Value::as_str)?.trim();
163 if message.is_empty() {
164 return None;
165 }
166 let path = r.get("path").and_then(Value::as_str).unwrap_or("").to_owned();
167 let file =
168 r.get("file").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_owned);
169 Some(SettingsParseErrorUpdate { file, path, message: message.to_owned() })
170}
171
172pub fn normalize_settings_parse_errors(value: &Value) -> Vec<SettingsParseErrorUpdate> {
174 if let Some(arr) = value.as_array() {
175 return arr.iter().filter_map(normalize_settings_parse_error).collect();
176 }
177 normalize_settings_parse_error(value).into_iter().collect()
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use serde_json::json;
184
185 #[test]
186 fn rate_limit_status_parses_known_values() {
187 assert_eq!(
188 parse_rate_limit_status(Some(&json!("allowed_warning"))),
189 Some(RateLimitStatus::AllowedWarning),
190 );
191 assert_eq!(
192 parse_rate_limit_status(Some(&json!("rejected"))),
193 Some(RateLimitStatus::Rejected)
194 );
195 assert_eq!(parse_rate_limit_status(Some(&json!("nope"))), Some(RateLimitStatus::Unknown));
197 }
198
199 #[test]
200 fn api_retry_error_falls_back_to_unknown() {
201 assert!(matches!(parse_api_retry_error(None), ApiRetryError::Unknown));
202 assert!(matches!(
203 parse_api_retry_error(Some(&json!("rate_limit"))),
204 ApiRetryError::RateLimit
205 ));
206 }
207
208 #[test]
209 fn rate_limit_update_requires_status() {
210 let v = json!({"resetsAt": 100.0});
211 assert!(build_rate_limit_update(Some(&v)).is_none());
212
213 let v = json!({"status": "allowed", "resetsAt": 100.0, "utilization": 0.5});
214 let u = build_rate_limit_update(Some(&v)).expect("update built");
215 assert_eq!(u.status, RateLimitStatus::Allowed);
216 assert_eq!(u.resets_at, Some(100.0));
217 assert_eq!(u.utilization, Some(0.5));
218 }
219
220 #[test]
221 fn api_retry_update_requires_three_numeric_fields() {
222 let map: Map<String, Value> =
223 serde_json::from_value(json!({"attempt": 1.0, "max_retries": 5.0})).unwrap();
224 assert!(build_api_retry_update(&map).is_none());
225
226 let map: Map<String, Value> = serde_json::from_value(json!({
227 "attempt": 1.0,
228 "max_retries": 5.0,
229 "retry_delay_ms": 1000.0,
230 "error": "rate_limit",
231 "error_status": 429.0,
232 }))
233 .unwrap();
234 let u = build_api_retry_update(&map).expect("update built");
235 assert_eq!(u.attempt, 1);
236 assert_eq!(u.max_retries, 5);
237 assert_eq!(u.retry_delay_ms, 1000);
238 assert_eq!(u.error_status, Some(429));
239 assert!(matches!(u.error, ApiRetryError::RateLimit));
240 }
241
242 #[test]
243 fn api_retry_update_drops_out_of_range_status_and_huge_counts() {
244 let map: Map<String, Value> = serde_json::from_value(json!({
246 "attempt": 1.0,
247 "max_retries": 5.0,
248 "retry_delay_ms": 1000.0,
249 "error_status": 70_000.0,
250 }))
251 .unwrap();
252 let u = build_api_retry_update(&map).expect("update built");
253 assert_eq!(u.error_status, None);
254
255 let map: Map<String, Value> = serde_json::from_value(json!({
257 "attempt": 1.0,
258 "max_retries": 5.0,
259 "retry_delay_ms": 1000.0,
260 "error_status": -1.0,
261 }))
262 .unwrap();
263 let u = build_api_retry_update(&map).expect("update built");
264 assert_eq!(u.error_status, None);
265
266 let map: Map<String, Value> = serde_json::from_value(json!({
268 "attempt": 1e30,
269 "max_retries": 5.0,
270 "retry_delay_ms": 1000.0,
271 }))
272 .unwrap();
273 assert!(build_api_retry_update(&map).is_none());
274
275 let map: Map<String, Value> = serde_json::from_value(json!({
277 "attempt": -1.0,
278 "max_retries": 5.0,
279 "retry_delay_ms": 1000.0,
280 }))
281 .unwrap();
282 assert!(build_api_retry_update(&map).is_none());
283
284 #[allow(clippy::cast_precision_loss)]
288 let boundary = u64::MAX as f64;
289 let map: Map<String, Value> = serde_json::from_value(json!({
290 "attempt": boundary,
291 "max_retries": 5.0,
292 "retry_delay_ms": 1000.0,
293 }))
294 .unwrap();
295 assert!(build_api_retry_update(&map).is_none());
296 }
297
298 #[test]
299 fn settings_parse_error_drops_empty_message() {
300 assert!(normalize_settings_parse_error(&json!({"message": " "})).is_none());
301 let entry = normalize_settings_parse_error(&json!({
302 "message": "broken json",
303 "path": "/etc/x.json",
304 "file": "x.json",
305 }))
306 .expect("entry");
307 assert_eq!(entry.message, "broken json");
308 assert_eq!(entry.path, "/etc/x.json");
309 assert_eq!(entry.file.as_deref(), Some("x.json"));
310 }
311}