Skip to main content

vtcode_core/llm/providers/openai/
errors.rs

1//! OpenAI provider error handling and formatting utilities.
2//!
3//! This module contains error detection, formatting, and recovery logic
4//! for OpenAI API interactions.
5
6use reqwest::StatusCode;
7use reqwest::header::HeaderMap;
8use serde_json::Value;
9
10use crate::config::constants::models;
11
12#[derive(Debug, Default, PartialEq, Eq)]
13struct OpenAIErrorDetails {
14    message: Option<String>,
15    code: Option<String>,
16    error_type: Option<String>,
17    param: Option<String>,
18}
19
20fn extract_header(headers: &HeaderMap, names: &[&str]) -> Option<String> {
21    names.iter().find_map(|name| {
22        headers
23            .get(*name)
24            .and_then(|value| value.to_str().ok())
25            .map(ToOwned::to_owned)
26    })
27}
28
29fn parse_openai_error_details(body: &str) -> OpenAIErrorDetails {
30    let Ok(json) = serde_json::from_str::<Value>(body) else {
31        return OpenAIErrorDetails::default();
32    };
33
34    let error = json.get("error").unwrap_or(&json);
35    OpenAIErrorDetails {
36        message: error
37            .get("message")
38            .and_then(Value::as_str)
39            .map(ToOwned::to_owned)
40            .filter(|value| !value.trim().is_empty())
41            .or_else(|| {
42                // FastAPI / alternate: {"detail": "..."}
43                json.get("detail")
44                    .and_then(Value::as_str)
45                    .map(ToOwned::to_owned)
46                    .filter(|value| !value.trim().is_empty())
47            }),
48        code: error
49            .get("code")
50            .and_then(Value::as_str)
51            .map(ToOwned::to_owned)
52            .filter(|value| !value.trim().is_empty()),
53        error_type: error
54            .get("type")
55            .and_then(Value::as_str)
56            .map(ToOwned::to_owned)
57            .filter(|value| !value.trim().is_empty()),
58        param: error
59            .get("param")
60            .and_then(Value::as_str)
61            .map(ToOwned::to_owned)
62            .filter(|value| !value.trim().is_empty()),
63    }
64}
65
66/// Detect if an OpenAI API error indicates the model was not found or is inaccessible.
67pub fn is_model_not_found(status: StatusCode, error_text: &str) -> bool {
68    if !matches!(
69        status,
70        StatusCode::NOT_FOUND | StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY
71    ) {
72        return false;
73    }
74
75    let lower = error_text.to_ascii_lowercase();
76    lower.contains("model_not_found")
77        || (lower.contains("model") && lower.contains("does not exist"))
78        || (lower.contains("model") && lower.contains("not found"))
79        || lower.contains("unknown model")
80}
81
82/// Provide a fallback model when the requested model is unavailable.
83pub fn fallback_model_if_not_found(model: &str) -> Option<String> {
84    match model {
85        m if m == models::openai::GPT_5_MINI => Some(models::openai::GPT_5.to_string()),
86        m if m == models::openai::GPT_5_NANO => Some(models::openai::GPT_5_MINI.to_string()),
87        _ => Some(models::openai::DEFAULT_MODEL.to_string()),
88    }
89}
90
91/// Format an OpenAI API error with request metadata.
92pub fn format_openai_error(
93    status: StatusCode,
94    body: &str,
95    headers: &HeaderMap,
96    context: &str,
97    client_request_id: Option<&str>,
98) -> String {
99    let request_id = extract_header(
100        headers,
101        &["x-request-id", "request-id", "openai-request-id"],
102    )
103    .unwrap_or_else(|| "<none>".to_string());
104    let effective_client_request_id = client_request_id
105        .map(str::trim)
106        .filter(|value| !value.is_empty())
107        .map(ToOwned::to_owned)
108        .or_else(|| extract_header(headers, &["x-client-request-id"]));
109    let organization_id = extract_header(headers, &["openai-organization", "x-organization-id"]);
110    let retry_after = extract_header(headers, &["retry-after"]);
111    let error_details = parse_openai_error_details(body);
112    let trimmed_body: String = body.chars().take(2_000).collect();
113    let mut metadata_parts = vec![format!("request_id={request_id}")];
114    if let Some(client_request_id) = effective_client_request_id {
115        metadata_parts.push(format!("client_request_id={client_request_id}"));
116    }
117    if let Some(code) = error_details.code.as_deref() {
118        metadata_parts.push(format!("code={code}"));
119    }
120    if let Some(error_type) = error_details.error_type.as_deref() {
121        metadata_parts.push(format!("type={error_type}"));
122    }
123    if let Some(param) = error_details.param.as_deref() {
124        metadata_parts.push(format!("param={param}"));
125    }
126    if let Some(retry_after) = retry_after.as_deref() {
127        metadata_parts.push(format!("retry_after={retry_after}"));
128    }
129    if let Some(organization_id) = organization_id.as_deref() {
130        metadata_parts.push(format!("organization={organization_id}"));
131    }
132
133    let mut formatted = format!(
134        "{} (status {}) [{}]",
135        context,
136        status,
137        metadata_parts.join(" ")
138    );
139    if let Some(message) = error_details.message.as_deref() {
140        formatted.push_str(&format!(" Message: {message}"));
141    }
142    if !trimmed_body.is_empty() && error_details.message.as_deref() != Some(trimmed_body.as_str()) {
143        formatted.push_str(&format!(" Body: {trimmed_body}"));
144    }
145    formatted
146}
147
148/// Detect if an error indicates Responses API is not supported for this model/endpoint.
149pub fn is_responses_api_unsupported(status: StatusCode, body: &str) -> bool {
150    let lower = body.to_ascii_lowercase();
151
152    (status == StatusCode::NOT_FOUND && lower.trim().is_empty())
153        || lower.contains("not enabled for the responses api")
154        || lower.contains("responses api")
155            && (lower.contains("unsupported") || lower.contains("not supported"))
156        || lower.contains("invalid api parameter")
157        || lower.contains("unsupported parameter")
158        || lower.contains("1210")
159        || lower.contains("invalid_request_error")
160}
161
162/// Detect if an OpenAI API error indicates `service_tier=flex` is unsupported.
163pub fn is_flex_service_tier_unsupported(status: StatusCode, body: &str) -> bool {
164    if status != StatusCode::BAD_REQUEST {
165        return false;
166    }
167
168    let lower = body.to_ascii_lowercase();
169    lower.contains("flex")
170        && (lower.contains("flex is not available for this model")
171            || (lower.contains("service tier") || lower.contains("service_tier"))
172                && (lower.contains("not available") || lower.contains("unsupported")))
173}
174
175#[cfg(test)]
176mod tests {
177    use super::{
178        OpenAIErrorDetails, format_openai_error, is_flex_service_tier_unsupported,
179        is_model_not_found, is_responses_api_unsupported, parse_openai_error_details,
180    };
181    use reqwest::StatusCode;
182    use reqwest::header::{HeaderMap, HeaderValue};
183
184    #[test]
185    fn model_not_found_requires_model_specific_body() {
186        assert!(!is_model_not_found(StatusCode::NOT_FOUND, ""));
187        assert!(is_model_not_found(StatusCode::NOT_FOUND, "model_not_found"));
188        assert!(is_model_not_found(
189            StatusCode::BAD_REQUEST,
190            "The requested model does not exist"
191        ));
192    }
193
194    #[test]
195    fn responses_api_unsupported_keeps_blank_404_fallback() {
196        assert!(is_responses_api_unsupported(StatusCode::NOT_FOUND, ""));
197        assert!(is_responses_api_unsupported(
198            StatusCode::BAD_REQUEST,
199            "This endpoint is not enabled for the Responses API"
200        ));
201        assert!(!is_responses_api_unsupported(
202            StatusCode::NOT_FOUND,
203            "model_not_found"
204        ));
205    }
206
207    #[test]
208    fn flex_service_tier_detection_matches_backend_error() {
209        assert!(is_flex_service_tier_unsupported(
210            StatusCode::BAD_REQUEST,
211            r#"{"error":{"message":"Flex is not available for this model.","type":"invalid_request_error"}}"#
212        ));
213        assert!(!is_flex_service_tier_unsupported(
214            StatusCode::BAD_REQUEST,
215            r#"{"error":{"message":"Bad request","type":"invalid_request_error"}}"#
216        ));
217        assert!(!is_flex_service_tier_unsupported(
218            StatusCode::TOO_MANY_REQUESTS,
219            r#"{"error":{"message":"Flex is not available for this model.","type":"invalid_request_error"}}"#
220        ));
221    }
222
223    #[test]
224    fn parse_openai_error_details_extracts_message_and_codes() {
225        let details = parse_openai_error_details(
226            r#"{"error":{"message":"Bad request","type":"invalid_request_error","param":"text.verbosity","code":"unsupported_parameter"}}"#,
227        );
228
229        assert_eq!(details.message.as_deref(), Some("Bad request"));
230        assert_eq!(details.error_type.as_deref(), Some("invalid_request_error"));
231        assert_eq!(details.param.as_deref(), Some("text.verbosity"));
232        assert_eq!(details.code.as_deref(), Some("unsupported_parameter"));
233    }
234
235    #[test]
236    fn parse_openai_error_details_handles_empty_body() {
237        let details = parse_openai_error_details("");
238
239        assert_eq!(details, OpenAIErrorDetails::default());
240    }
241
242    #[test]
243    fn format_openai_error_surfaces_debugging_metadata() {
244        let mut headers = HeaderMap::new();
245        headers.insert("x-request-id", HeaderValue::from_static("req_123"));
246        headers.insert("retry-after", HeaderValue::from_static("30"));
247        headers.insert("openai-organization", HeaderValue::from_static("org_456"));
248
249        let formatted = format_openai_error(
250            StatusCode::BAD_REQUEST,
251            r#"{"error":{"message":"Bad request","type":"invalid_request_error","param":"text.verbosity","code":"unsupported_parameter"}}"#,
252            &headers,
253            "Responses API error",
254            Some("vtcode-abc"),
255        );
256
257        assert!(formatted.contains("request_id=req_123"));
258        assert!(formatted.contains("client_request_id=vtcode-abc"));
259        assert!(formatted.contains("retry_after=30"));
260        assert!(formatted.contains("organization=org_456"));
261        assert!(formatted.contains("type=invalid_request_error"));
262        assert!(formatted.contains("code=unsupported_parameter"));
263        assert!(formatted.contains("param=text.verbosity"));
264        assert!(formatted.contains("Message: Bad request"));
265    }
266}