agent_engine/runtime/openai/
net.rs1use crate::core::error::{error_chain_string, humanize_provider_network_error};
19use crate::auth::RefreshFailureKind;
20use crate::RuntimeError;
21
22pub type BoxedProviderError = Box<dyn std::error::Error + Send + Sync>;
24
25pub fn provider_error_to_runtime(e: BoxedProviderError) -> RuntimeError {
33 provider_error_to_runtime_for("", e)
34}
35
36pub fn provider_error_to_runtime_for(qualified_model: &str, e: BoxedProviderError) -> RuntimeError {
44 if let Some(re) = find_reqwest_error(e.as_ref()) {
46 let msg = humanize_provider_network_error(re);
47 tracing::warn!(error = %error_chain_string(e.as_ref()), "provider transport error");
48 return RuntimeError::ApiStatus(msg);
49 }
50
51 let msg = error_chain_string(e.as_ref());
52
53 if msg.contains("operation canceled") || msg.contains("request canceled") {
54 return RuntimeError::Canceled;
55 }
56
57 if let Some(refresh) = find_in_chain::<BrokerRefreshError>(e.as_ref()) {
62 if refresh.kind.is_retryable() || refresh.kind == RefreshFailureKind::ActionRequired {
63 tracing::warn!(error = %msg, "provider credential refresh failed");
64 return RuntimeError::ApiStatus(msg);
65 }
66 }
67
68 if msg.starts_with("codex request failed:")
71 || msg.starts_with("openai request failed:")
72 || is_responses_terminal_failure_message(&msg)
73 {
74 tracing::warn!(error = %msg, "provider API error");
75 if let Some(human) = humanize_broker_status_message(qualified_model, &msg) {
76 return RuntimeError::ApiStatus(human);
77 }
78 return RuntimeError::ApiStatus(msg);
79 }
80
81 tracing::warn!(error = %msg, "provider config error");
82 RuntimeError::Config(format!("openai provider: {msg}"))
83}
84
85fn humanize_broker_status_message(qualified_model: &str, msg: &str) -> Option<String> {
89 let provider = qualified_model.split_once('/')?.0;
90 if provider.is_empty() {
91 return None;
92 }
93 let status = crate::runtime::trace::openai::broker_error_status(msg)?;
94 let label = broker_error_label(msg)?;
95 crate::core::error::humanize_proxy_status_error(provider, status, Some(label))
96}
97
98pub(super) fn normalize_broker_http_error(message: String) -> String {
101 let valid = message
102 .strip_prefix("broker transport error: provider request failed: ")
103 .is_some_and(|rest| {
104 let Some((status, tail)) = rest.split_once(' ') else {
105 return false;
106 };
107 if status.len() != 3 || !status.bytes().all(|b| b.is_ascii_digit()) {
108 return false;
109 }
110 let Ok(status) = status
111 .parse::<u16>()
112 .ok()
113 .and_then(|n| reqwest::StatusCode::from_u16(n).ok())
114 .ok_or(())
115 else {
116 return false;
117 };
118 if !status.is_client_error() && !status.is_server_error() {
119 return false;
120 }
121 let Some(reason) = status.canonical_reason() else {
122 return false;
123 };
124 if tail == reason {
125 return true;
126 }
127 tail.strip_prefix(reason)
128 .and_then(|r| r.strip_prefix(" ["))
129 .and_then(|r| r.strip_suffix(']'))
130 .is_some_and(|label| crate::core::error::VETTED_PROXY_ERROR_TYPES.contains(&label))
131 });
132 if valid {
133 format!("openai request failed: {message}")
134 } else {
135 message
136 }
137}
138
139pub(crate) fn broker_error_label(msg: &str) -> Option<&str> {
143 const MARKER: &str = "provider request failed: ";
144 let rest = &msg[msg.find(MARKER)? + MARKER.len()..];
145 let open = rest.find(" [")?;
146 let after = &rest[open + 2..];
147 let close = after.find(']')?;
148 let label = &after[..close];
149 (!label.is_empty()
150 && label
151 .bytes()
152 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'))
153 .then_some(label)
154}
155
156fn is_responses_terminal_failure_message(msg: &str) -> bool {
161 use super::stream::{
162 RESPONSES_AUTH_SUFFIX, RESPONSES_CAPACITY_SUFFIX, RESPONSES_CONTEXT_SUFFIX,
163 RESPONSES_EMPTY_SUFFIX, RESPONSES_FAILED_SUFFIX, RESPONSES_INCOMPLETE_SUFFIX,
164 RESPONSES_MISSING_TERMINAL_SUFFIX, RESPONSES_QUOTA_SUFFIX,
165 };
166 const SUFFIXES: &[&str] = &[
167 RESPONSES_AUTH_SUFFIX,
168 RESPONSES_QUOTA_SUFFIX,
169 RESPONSES_FAILED_SUFFIX,
170 RESPONSES_CAPACITY_SUFFIX,
171 RESPONSES_CONTEXT_SUFFIX,
172 RESPONSES_INCOMPLETE_SUFFIX,
173 RESPONSES_EMPTY_SUFFIX,
174 RESPONSES_MISSING_TERMINAL_SUFFIX,
175 ];
176 SUFFIXES.iter().any(|suffix| {
177 msg.strip_suffix(suffix)
178 .is_some_and(|label| !label.is_empty() && !label.contains(char::is_whitespace))
179 })
180}
181
182#[derive(Debug)]
186pub(crate) struct BrokerRefreshError {
187 pub(crate) kind: RefreshFailureKind,
188 message: String,
189}
190
191impl BrokerRefreshError {
192 pub(crate) fn new(failure: crate::auth::RefreshFailure) -> Self {
193 let kind = failure.kind;
194 Self {
195 kind,
196 message: crate::auth::BrokerError::Refresh(failure).to_string(),
197 }
198 }
199}
200
201impl std::fmt::Display for BrokerRefreshError {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.write_str(&self.message)
204 }
205}
206
207impl std::error::Error for BrokerRefreshError {}
208
209fn find_in_chain<'a, T: std::error::Error + 'static>(
210 e: &'a (dyn std::error::Error + 'static),
211) -> Option<&'a T> {
212 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(e);
213 while let Some(err) = current {
214 if let Some(found) = err.downcast_ref::<T>() {
215 return Some(found);
216 }
217 current = err.source();
218 }
219 None
220}
221
222fn find_reqwest_error<'a>(e: &'a (dyn std::error::Error + 'static)) -> Option<&'a reqwest::Error> {
227 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(e);
228 while let Some(err) = current {
229 if let Some(re) = err.downcast_ref::<reqwest::Error>() {
230 return Some(re);
231 }
232 current = err.source();
233 }
234 None
235}
236
237pub(crate) fn redact_provider_proxy_error(msg: &str) -> String {
248 const MARKER: &str = "provider request failed: ";
249 let Some(start) = msg.find(MARKER) else {
250 return msg.to_string();
251 };
252 let status_start = start + MARKER.len();
253 match msg[status_start..].find(':') {
254 Some(idx) => msg[..status_start + idx].to_string(),
255 None => msg.to_string(),
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 #[test]
262 fn broker_http_envelope_retains_provider_error_provenance() {
263 use crate::extensions::session_driver::{classify_turn_error, Outcome};
264 for (tail, expected) in [
265 ("401 Unauthorized", Some("auth")),
266 ("402 Payment Required", Some("quota")),
267 ("429 Too Many Requests", Some("rate_limit")),
268 ("429 Too Many Requests [insufficient_quota]", Some("quota")),
269 ("503 Service Unavailable", Some("transient")),
270 ("403 Forbidden", None),
271 ] {
272 let message = super::normalize_broker_http_error(format!(
273 "broker transport error: provider request failed: {tail}"
274 ));
275 assert!(message.starts_with("openai request failed: "));
276 let runtime = super::provider_error_to_runtime_for("xai-auth/grok-4.6", message.into());
277 assert!(matches!(runtime, crate::RuntimeError::ApiStatus(_)));
278 let result =
279 classify_turn_error(&crate::runtime::helpers::turn_error_for(&runtime, "test"));
280 match expected {
281 Some(kind) => assert_eq!(result, (Outcome::ProviderError, kind.into())),
282 None => assert_eq!(result.0, Outcome::Blocked),
283 }
284 }
285 for message in [
286 "broker transport error: private config problem",
287 "broker transport error: provider request failed: 401 untrusted reason",
288 "broker transport error: provider request failed: 401 Unauthorized: private body",
289 "broker transport error: provider request failed: 429 Too Many Requests [unlisted_error]",
290 "quoted broker transport error: provider request failed: 503 Service Unavailable",
291 ] {
292 assert_eq!(super::normalize_broker_http_error(message.into()), message);
293 }
294 }
295 use super::*;
296
297 async fn connect_error() -> reqwest::Error {
298 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
300 let addr = listener.local_addr().unwrap();
301 drop(listener);
302 reqwest::Client::new()
303 .post(format!("http://{addr}/codex/responses"))
304 .send()
305 .await
306 .expect_err("must fail to connect")
307 }
308
309 #[tokio::test]
310 async fn transport_error_is_api_status_not_config() {
311 let err = connect_error().await;
312 let runtime_err = provider_error_to_runtime(Box::new(err));
313 assert!(
314 matches!(runtime_err, RuntimeError::ApiStatus(_)),
315 "transport failure must classify as ApiStatus, got: {runtime_err:?}"
316 );
317 let display = runtime_err.to_string();
318 assert!(
319 !display.starts_with("Config error"),
320 "transient network failure must not be presented as a config problem: {display}"
321 );
322 assert!(
323 display.contains("127.0.0.1"),
324 "must name the host: {display}"
325 );
326 }
327
328 #[tokio::test]
329 async fn transport_error_preserves_cause_chain() {
330 let err = connect_error().await;
331 let display = provider_error_to_runtime(Box::new(err)).to_string();
332 assert!(
335 display.contains("refused") || display.contains("connect"),
336 "underlying cause must survive classification: {display}"
337 );
338 }
339
340 #[test]
341 fn upstream_status_error_is_api_status() {
342 let e: BoxedProviderError = "codex request failed: 503: upstream sad".into();
343 let runtime_err = provider_error_to_runtime(e);
344 assert!(
345 matches!(runtime_err, RuntimeError::ApiStatus(_)),
346 "HTTP status failure must classify as ApiStatus, got: {runtime_err:?}"
347 );
348 assert!(runtime_err.to_string().contains("503"));
349 }
350
351 #[test]
352 fn openai_status_error_is_api_status() {
353 let e: BoxedProviderError = "openai request failed: 429: slow down".into();
354 assert!(matches!(
355 provider_error_to_runtime(e),
356 RuntimeError::ApiStatus(_)
357 ));
358 }
359
360 #[test]
361 fn responses_terminal_errors_are_api_status_not_config() {
362 for msg in [
363 "Codex response failed in stream. Provider error details withheld because they can echo request content.",
364 "Codex response was incomplete. Retry the request or reduce the requested output/context size.",
365 "Codex completed without text or tool output. Retry the request.",
366 "Codex response stream ended without a terminal event. Retry the request.",
367 "Codex rejected the request: the conversation exceeds this model's context window. Run /compact or start a fresh session to continue.",
368 "xAI response failed in stream. Provider error details withheld because they can echo request content.",
370 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model.",
371 "xAI completed without text or tool output. Retry the request.",
372 "xAI response stream ended without a terminal event. Retry the request.",
373 "xAI response was incomplete. Retry the request or reduce the requested output/context size.",
374 ] {
375 let err = provider_error_to_runtime(msg.into());
376 assert!(
377 matches!(err, RuntimeError::ApiStatus(_)),
378 "Responses terminal must classify as ApiStatus: {err:?}"
379 );
380 assert!(!err.to_string().starts_with("Config error"));
381 }
382 }
383
384 #[test]
387 fn responses_terminal_matcher_requires_bare_label_prefix() {
388 assert!(!is_responses_terminal_failure_message(
389 "No API key for 'xai'. response failed in stream."
390 ));
391 assert!(!is_responses_terminal_failure_message(
392 " response failed in stream. Provider error details withheld because they can echo request content."
393 ));
394 assert!(is_responses_terminal_failure_message(
395 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
396 ));
397 }
398
399 #[test]
400 fn cancellation_is_canceled() {
401 let e: BoxedProviderError = "operation canceled".into();
402 assert!(matches!(
403 provider_error_to_runtime(e),
404 RuntimeError::Canceled
405 ));
406 let e: BoxedProviderError = "request canceled".into();
407 assert!(matches!(
408 provider_error_to_runtime(e),
409 RuntimeError::Canceled
410 ));
411 }
412
413 #[test]
414 fn redact_provider_proxy_error_drops_body_snippet_keeps_status() {
415 let msg = "gemini stream error: broker transport error: provider request failed: \
416 429 Too Many Requests: {\"error\":{\"message\":\"ECHOED:secret\"}}";
417 let redacted = redact_provider_proxy_error(msg);
418 assert_eq!(
419 redacted,
420 "gemini stream error: broker transport error: provider request failed: \
421 429 Too Many Requests"
422 );
423 }
424
425 #[test]
426 fn redact_provider_proxy_error_passes_through_non_proxy_messages() {
427 for msg in [
428 "broker transport error: connection reset",
429 "no credential configured for 'groq'. Run `synaps login` to add one.",
430 "request canceled",
431 ] {
432 assert_eq!(redact_provider_proxy_error(msg), msg);
433 }
434 }
435
436 #[test]
437 fn genuine_config_problems_stay_config_with_original_prefix() {
438 let e: BoxedProviderError =
439 "No API key for 'groq'. Set provider.groq in ~/.synaps-cli/config or the corresponding env var."
440 .into();
441 let runtime_err = provider_error_to_runtime(e);
442 assert!(
443 matches!(runtime_err, RuntimeError::Config(_)),
444 "missing key IS a config problem, got: {runtime_err:?}"
445 );
446 assert!(
447 runtime_err
448 .to_string()
449 .starts_with("Config error: openai provider: "),
450 "must keep the historical prefix for genuine config errors: {runtime_err}"
451 );
452 }
453
454 #[test]
458 fn vetted_kimi_quota_label_is_humanized_for_known_provider() {
459 let raw = "openai request failed: broker transport error: provider request failed: 403 Forbidden [access_terminated_error]";
460 let err: BoxedProviderError = raw.into();
461 let runtime_err = provider_error_to_runtime_for("kimi-code/k3", err);
462 let RuntimeError::ApiStatus(msg) = runtime_err else {
463 panic!("expected ApiStatus, got {runtime_err:?}");
464 };
465 assert!(
466 msg.starts_with("kimi-code: usage quota exhausted (HTTP 403)"),
467 "{msg}"
468 );
469 assert!(msg.contains("weekly"), "{msg}");
470 assert!(
471 !msg.contains("login"),
472 "quota must not read as an auth failure: {msg}"
473 );
474 assert!(
475 !msg.contains("[access_terminated_error]"),
476 "label must be rendered, not leaked: {msg}"
477 );
478 }
479
480 #[test]
483 fn broker_status_without_label_or_provider_keeps_raw_message() {
484 let labelled = "openai request failed: broker transport error: provider request failed: 403 Forbidden [access_terminated_error]";
485 match provider_error_to_runtime(labelled.into()) {
486 RuntimeError::ApiStatus(msg) => assert_eq!(msg, labelled),
487 other => panic!("expected ApiStatus, got {other:?}"),
488 }
489 let plain =
490 "openai request failed: broker transport error: provider request failed: 403 Forbidden";
491 match provider_error_to_runtime_for("kimi-code/k3", plain.into()) {
492 RuntimeError::ApiStatus(msg) => assert_eq!(msg, plain),
493 other => panic!("expected ApiStatus, got {other:?}"),
494 }
495 let unknown =
497 "openai request failed: provider request failed: 418 I'm a teapot [server_error]";
498 match provider_error_to_runtime_for("groq/llama", unknown.into()) {
499 RuntimeError::ApiStatus(msg) => assert_eq!(msg, unknown),
500 other => panic!("expected ApiStatus, got {other:?}"),
501 }
502 }
503
504 #[test]
505 fn broker_error_label_accepts_only_identifier_shaped_labels() {
506 assert_eq!(
507 broker_error_label(
508 "x: provider request failed: 403 Forbidden [access_terminated_error]"
509 ),
510 Some("access_terminated_error")
511 );
512 assert_eq!(
513 broker_error_label("provider request failed: 403 Forbidden"),
514 None
515 );
516 assert_eq!(
517 broker_error_label("provider request failed: 403 Forbidden []"),
518 None
519 );
520 assert_eq!(
521 broker_error_label("provider request failed: 403 Forbidden [<script>ECHO]"),
522 None
523 );
524 assert_eq!(
525 broker_error_label("provider request failed: 403 Forbidden [Mixed-Case]"),
526 None
527 );
528 assert_eq!(
529 broker_error_label("no marker here [access_terminated_error]"),
530 None
531 );
532 }
533}