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