vtcode_core/llm/providers/openai/
errors.rs1use 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 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
66pub 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
82pub 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
91pub 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
148pub 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
162pub 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}