Skip to main content

agent_engine/runtime/openai/
net.rs

1//! Error classification for the OpenAI-compat / Codex provider routes.
2//!
3//! Historically every error escaping `try_route` was blanket-wrapped in
4//! `RuntimeError::Config("openai provider: …")` — so a transient network
5//! blip against `chatgpt.com/backend-api/codex/responses` surfaced to the
6//! user as **"Config error"**, implying their configuration was broken
7//! (real incident: session 20260714-025948-3dab). This module classifies
8//! provider-route errors into honest categories:
9//!
10//! * transport failures (`reqwest::Error`) → `RuntimeError::ApiStatus` with
11//!   a host-aware humanized message that preserves the full cause chain
12//! * upstream HTTP status failures (`codex request failed: 503: …`) →
13//!   `RuntimeError::ApiStatus`
14//! * cancellation → `RuntimeError::Canceled`
15//! * everything else (missing keys, malformed tokens, …) → genuine
16//!   `RuntimeError::Config`, same prefix as before
17
18use crate::core::error::{error_chain_string, humanize_provider_network_error};
19use crate::auth::RefreshFailureKind;
20use crate::RuntimeError;
21
22/// Boxed error type produced by the provider routes.
23pub type BoxedProviderError = Box<dyn std::error::Error + Send + Sync>;
24
25/// Classify an error escaping the OpenAI/Codex provider route into the
26/// right `RuntimeError` variant. Also logs the full cause chain at WARN —
27/// provider-route failures were previously invisible in the logs.
28///
29/// Provider-agnostic form: broker status labels are classified but not
30/// humanized. Prefer [`provider_error_to_runtime_for`] when the qualified
31/// model (and therefore the provider key) is known.
32pub fn provider_error_to_runtime(e: BoxedProviderError) -> RuntimeError {
33    provider_error_to_runtime_for("", e)
34}
35
36/// Like [`provider_error_to_runtime`], but `qualified_model` (e.g.
37/// `kimi-code/k3`) lets a vetted broker status label such as
38/// `provider request failed: 403 Forbidden [access_terminated_error]` be
39/// rendered as an actionable, provider-aware message. The label is one of
40/// OUR static constants (selected broker-side, never provider bytes) and the
41/// humanizer only ever returns our own text; the raw prefix message is kept
42/// for the log line.
43pub fn provider_error_to_runtime_for(qualified_model: &str, e: BoxedProviderError) -> RuntimeError {
44    // Transport-level failure (connect / timeout / mid-stream drop)?
45    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    // A broker credential-refresh failure, typed by the stream path: a
58    // retryable or action-required one (e.g. a Muse re-mint that hit the
59    // network, a billing requirement) is neither a configuration problem
60    // nor a login problem.
61    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    // Upstream returned an HTTP error status or the Responses API reported a
69    // terminal stream failure — API failures, not configuration errors.
70    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
85/// Render a broker status failure carrying a vetted label as an actionable
86/// message. `None` when the message has no label, the label is unknown to
87/// the humanizer, or the provider key cannot be derived from the model.
88fn 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
98/// Admit only the complete broker-authored HTTP status envelope. Other broker
99/// errors (policy/config/credentials/transport ambiguity) keep their category.
100pub(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
139/// Extract the `[label]` the broker appends after the canonical reason
140/// phrase (`provider request failed: 403 Forbidden [access_terminated_error]`).
141/// Only identifier-shaped labels are accepted; anything else is `None`.
142pub(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
156/// Responses-API terminal stream failures are `"{label}{suffix}"` where the
157/// label is a provider display name ("Codex", "xAI") and the suffix is one of
158/// the static templates in `stream.rs`. Match label-independently on the
159/// suffix so every provider label classifies as an API failure.
160fn 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/// A `BrokerError::Refresh` from `proxy_stream`, kept typed so its kind
183/// classifies the turn error (no matching on our own hint text). Display is
184/// the broker's text: ours, with no upstream bytes.
185#[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
222/// Walk the boxed error and its source chain looking for a `reqwest::Error`.
223///
224/// `send().await?` boxes the `reqwest::Error` directly, but helpers may wrap
225/// it another level deep — check the whole chain, not just the top.
226fn 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
237/// Strip the provider response-body snippet from a broker proxy error.
238///
239/// `LocalBroker::proxy_stream` flattens an upstream HTTP failure into
240/// `… provider request failed: {status}: {body snippet}`. The snippet is
241/// provider-controlled and may echo the full request (spec §5.1) — a byte
242/// bound is not redaction. Keep everything up to and including the status
243/// (canonical reason phrase, no `:`), drop the snippet. Messages without the
244/// marker pass through unchanged: every other `BrokerError` variant carries
245/// broker-authored, secret-free text, and transport-level reqwest errors are
246/// not response bodies.
247pub(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        // Bind then drop → guaranteed-refused port.
299        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        // The top-level reqwest Display alone says only "error sending
333        // request" — the classified message must keep the underlying cause.
334        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-labelled variants (xai-auth Responses route).
369            "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    /// A message that merely mentions a suffix mid-sentence (or carries a
385    /// non-label prefix) must not be mistaken for a Responses terminal.
386    #[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    /// A broker status failure carrying a vetted label is humanized into an
455    /// actionable, provider-aware message when the qualified model is known
456    /// — and never echoes anything but our own text.
457    #[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    /// Without a provider key (legacy entry point) or without a label the
481    /// raw, status-only prefix message is preserved verbatim.
482    #[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        // Unknown (status, label) combos also fall through unchanged.
496        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}