agent_engine/runtime/openai/
net.rs1use crate::core::error::{error_chain_string, humanize_provider_network_error};
19use crate::RuntimeError;
20
21pub type BoxedProviderError = Box<dyn std::error::Error + Send + Sync>;
23
24pub fn provider_error_to_runtime(e: BoxedProviderError) -> RuntimeError {
32 provider_error_to_runtime_for("", e)
33}
34
35pub fn provider_error_to_runtime_for(qualified_model: &str, e: BoxedProviderError) -> RuntimeError {
43 if let Some(re) = find_reqwest_error(e.as_ref()) {
45 let msg = humanize_provider_network_error(re);
46 tracing::warn!(error = %error_chain_string(e.as_ref()), "provider transport error");
47 return RuntimeError::ApiStatus(msg);
48 }
49
50 let msg = error_chain_string(e.as_ref());
51
52 if msg.contains("operation canceled") || msg.contains("request canceled") {
53 return RuntimeError::Canceled;
54 }
55
56 if msg.starts_with("codex request failed:")
59 || msg.starts_with("openai request failed:")
60 || is_responses_terminal_failure_message(&msg)
61 {
62 tracing::warn!(error = %msg, "provider API error");
63 if let Some(human) = humanize_broker_status_message(qualified_model, &msg) {
64 return RuntimeError::ApiStatus(human);
65 }
66 return RuntimeError::ApiStatus(msg);
67 }
68
69 tracing::warn!(error = %msg, "provider config error");
70 RuntimeError::Config(format!("openai provider: {msg}"))
71}
72
73fn humanize_broker_status_message(qualified_model: &str, msg: &str) -> Option<String> {
77 let provider = qualified_model.split_once('/')?.0;
78 if provider.is_empty() {
79 return None;
80 }
81 let status = crate::runtime::trace::openai::broker_error_status(msg)?;
82 let label = broker_error_label(msg)?;
83 crate::core::error::humanize_proxy_status_error(provider, status, Some(label))
84}
85
86pub(crate) fn broker_error_label(msg: &str) -> Option<&str> {
90 const MARKER: &str = "provider request failed: ";
91 let rest = &msg[msg.find(MARKER)? + MARKER.len()..];
92 let open = rest.find(" [")?;
93 let after = &rest[open + 2..];
94 let close = after.find(']')?;
95 let label = &after[..close];
96 (!label.is_empty()
97 && label
98 .bytes()
99 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'))
100 .then_some(label)
101}
102
103fn is_responses_terminal_failure_message(msg: &str) -> bool {
108 use super::stream::{
109 RESPONSES_CAPACITY_SUFFIX, RESPONSES_CONTEXT_SUFFIX, RESPONSES_EMPTY_SUFFIX,
110 RESPONSES_FAILED_SUFFIX, RESPONSES_INCOMPLETE_SUFFIX, RESPONSES_MISSING_TERMINAL_SUFFIX,
111 };
112 const SUFFIXES: &[&str] = &[
113 RESPONSES_FAILED_SUFFIX,
114 RESPONSES_CAPACITY_SUFFIX,
115 RESPONSES_CONTEXT_SUFFIX,
116 RESPONSES_INCOMPLETE_SUFFIX,
117 RESPONSES_EMPTY_SUFFIX,
118 RESPONSES_MISSING_TERMINAL_SUFFIX,
119 ];
120 SUFFIXES.iter().any(|suffix| {
121 msg.strip_suffix(suffix)
122 .is_some_and(|label| !label.is_empty() && !label.contains(char::is_whitespace))
123 })
124}
125
126fn find_reqwest_error<'a>(e: &'a (dyn std::error::Error + 'static)) -> Option<&'a reqwest::Error> {
131 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(e);
132 while let Some(err) = current {
133 if let Some(re) = err.downcast_ref::<reqwest::Error>() {
134 return Some(re);
135 }
136 current = err.source();
137 }
138 None
139}
140
141pub(crate) fn redact_provider_proxy_error(msg: &str) -> String {
152 const MARKER: &str = "provider request failed: ";
153 let Some(start) = msg.find(MARKER) else {
154 return msg.to_string();
155 };
156 let status_start = start + MARKER.len();
157 match msg[status_start..].find(':') {
158 Some(idx) => msg[..status_start + idx].to_string(),
159 None => msg.to_string(),
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 async fn connect_error() -> reqwest::Error {
168 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
170 let addr = listener.local_addr().unwrap();
171 drop(listener);
172 reqwest::Client::new()
173 .post(format!("http://{addr}/codex/responses"))
174 .send()
175 .await
176 .expect_err("must fail to connect")
177 }
178
179 #[tokio::test]
180 async fn transport_error_is_api_status_not_config() {
181 let err = connect_error().await;
182 let runtime_err = provider_error_to_runtime(Box::new(err));
183 assert!(
184 matches!(runtime_err, RuntimeError::ApiStatus(_)),
185 "transport failure must classify as ApiStatus, got: {runtime_err:?}"
186 );
187 let display = runtime_err.to_string();
188 assert!(
189 !display.starts_with("Config error"),
190 "transient network failure must not be presented as a config problem: {display}"
191 );
192 assert!(
193 display.contains("127.0.0.1"),
194 "must name the host: {display}"
195 );
196 }
197
198 #[tokio::test]
199 async fn transport_error_preserves_cause_chain() {
200 let err = connect_error().await;
201 let display = provider_error_to_runtime(Box::new(err)).to_string();
202 assert!(
205 display.contains("refused") || display.contains("connect"),
206 "underlying cause must survive classification: {display}"
207 );
208 }
209
210 #[test]
211 fn upstream_status_error_is_api_status() {
212 let e: BoxedProviderError = "codex request failed: 503: upstream sad".into();
213 let runtime_err = provider_error_to_runtime(e);
214 assert!(
215 matches!(runtime_err, RuntimeError::ApiStatus(_)),
216 "HTTP status failure must classify as ApiStatus, got: {runtime_err:?}"
217 );
218 assert!(runtime_err.to_string().contains("503"));
219 }
220
221 #[test]
222 fn openai_status_error_is_api_status() {
223 let e: BoxedProviderError = "openai request failed: 429: slow down".into();
224 assert!(matches!(
225 provider_error_to_runtime(e),
226 RuntimeError::ApiStatus(_)
227 ));
228 }
229
230 #[test]
231 fn responses_terminal_errors_are_api_status_not_config() {
232 for msg in [
233 "Codex response failed in stream. Provider error details withheld because they can echo request content.",
234 "Codex response was incomplete. Retry the request or reduce the requested output/context size.",
235 "Codex completed without text or tool output. Retry the request.",
236 "Codex response stream ended without a terminal event. Retry the request.",
237 "Codex rejected the request: the conversation exceeds this model's context window. Run /compact or start a fresh session to continue.",
238 "xAI response failed in stream. Provider error details withheld because they can echo request content.",
240 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model.",
241 "xAI completed without text or tool output. Retry the request.",
242 "xAI response stream ended without a terminal event. Retry the request.",
243 "xAI response was incomplete. Retry the request or reduce the requested output/context size.",
244 ] {
245 let err = provider_error_to_runtime(msg.into());
246 assert!(
247 matches!(err, RuntimeError::ApiStatus(_)),
248 "Responses terminal must classify as ApiStatus: {err:?}"
249 );
250 assert!(!err.to_string().starts_with("Config error"));
251 }
252 }
253
254 #[test]
257 fn responses_terminal_matcher_requires_bare_label_prefix() {
258 assert!(!is_responses_terminal_failure_message(
259 "No API key for 'xai'. response failed in stream."
260 ));
261 assert!(!is_responses_terminal_failure_message(
262 " response failed in stream. Provider error details withheld because they can echo request content."
263 ));
264 assert!(is_responses_terminal_failure_message(
265 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
266 ));
267 }
268
269 #[test]
270 fn cancellation_is_canceled() {
271 let e: BoxedProviderError = "operation canceled".into();
272 assert!(matches!(
273 provider_error_to_runtime(e),
274 RuntimeError::Canceled
275 ));
276 let e: BoxedProviderError = "request canceled".into();
277 assert!(matches!(
278 provider_error_to_runtime(e),
279 RuntimeError::Canceled
280 ));
281 }
282
283 #[test]
284 fn redact_provider_proxy_error_drops_body_snippet_keeps_status() {
285 let msg = "gemini stream error: broker transport error: provider request failed: \
286 429 Too Many Requests: {\"error\":{\"message\":\"ECHOED:secret\"}}";
287 let redacted = redact_provider_proxy_error(msg);
288 assert_eq!(
289 redacted,
290 "gemini stream error: broker transport error: provider request failed: \
291 429 Too Many Requests"
292 );
293 }
294
295 #[test]
296 fn redact_provider_proxy_error_passes_through_non_proxy_messages() {
297 for msg in [
298 "broker transport error: connection reset",
299 "no credential configured for 'groq'. Run `synaps login` to add one.",
300 "request canceled",
301 ] {
302 assert_eq!(redact_provider_proxy_error(msg), msg);
303 }
304 }
305
306 #[test]
307 fn genuine_config_problems_stay_config_with_original_prefix() {
308 let e: BoxedProviderError =
309 "No API key for 'groq'. Set provider.groq in ~/.synaps-cli/config or the corresponding env var."
310 .into();
311 let runtime_err = provider_error_to_runtime(e);
312 assert!(
313 matches!(runtime_err, RuntimeError::Config(_)),
314 "missing key IS a config problem, got: {runtime_err:?}"
315 );
316 assert!(
317 runtime_err
318 .to_string()
319 .starts_with("Config error: openai provider: "),
320 "must keep the historical prefix for genuine config errors: {runtime_err}"
321 );
322 }
323
324 #[test]
328 fn vetted_kimi_quota_label_is_humanized_for_known_provider() {
329 let raw = "openai request failed: broker transport error: provider request failed: 403 Forbidden [access_terminated_error]";
330 let err: BoxedProviderError = raw.into();
331 let runtime_err = provider_error_to_runtime_for("kimi-code/k3", err);
332 let RuntimeError::ApiStatus(msg) = runtime_err else {
333 panic!("expected ApiStatus, got {runtime_err:?}");
334 };
335 assert!(
336 msg.starts_with("kimi-code: usage quota exhausted (HTTP 403)"),
337 "{msg}"
338 );
339 assert!(msg.contains("weekly"), "{msg}");
340 assert!(
341 !msg.contains("login"),
342 "quota must not read as an auth failure: {msg}"
343 );
344 assert!(
345 !msg.contains("[access_terminated_error]"),
346 "label must be rendered, not leaked: {msg}"
347 );
348 }
349
350 #[test]
353 fn broker_status_without_label_or_provider_keeps_raw_message() {
354 let labelled = "openai request failed: broker transport error: provider request failed: 403 Forbidden [access_terminated_error]";
355 match provider_error_to_runtime(labelled.into()) {
356 RuntimeError::ApiStatus(msg) => assert_eq!(msg, labelled),
357 other => panic!("expected ApiStatus, got {other:?}"),
358 }
359 let plain =
360 "openai request failed: broker transport error: provider request failed: 403 Forbidden";
361 match provider_error_to_runtime_for("kimi-code/k3", plain.into()) {
362 RuntimeError::ApiStatus(msg) => assert_eq!(msg, plain),
363 other => panic!("expected ApiStatus, got {other:?}"),
364 }
365 let unknown =
367 "openai request failed: provider request failed: 418 I'm a teapot [server_error]";
368 match provider_error_to_runtime_for("groq/llama", unknown.into()) {
369 RuntimeError::ApiStatus(msg) => assert_eq!(msg, unknown),
370 other => panic!("expected ApiStatus, got {other:?}"),
371 }
372 }
373
374 #[test]
375 fn broker_error_label_accepts_only_identifier_shaped_labels() {
376 assert_eq!(
377 broker_error_label(
378 "x: provider request failed: 403 Forbidden [access_terminated_error]"
379 ),
380 Some("access_terminated_error")
381 );
382 assert_eq!(
383 broker_error_label("provider request failed: 403 Forbidden"),
384 None
385 );
386 assert_eq!(
387 broker_error_label("provider request failed: 403 Forbidden []"),
388 None
389 );
390 assert_eq!(
391 broker_error_label("provider request failed: 403 Forbidden [<script>ECHO]"),
392 None
393 );
394 assert_eq!(
395 broker_error_label("provider request failed: 403 Forbidden [Mixed-Case]"),
396 None
397 );
398 assert_eq!(
399 broker_error_label("no marker here [access_terminated_error]"),
400 None
401 );
402 }
403}