Skip to main content

ai_agent/services/api/
errors.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/utils/errors.ts
2//! API error types and utilities translated from TypeScript errors.ts
3
4/// Prefix for API error messages
5pub const API_ERROR_MESSAGE_PREFIX: &str = "API Error";
6
7/// Sanitize an HTTP error body: if it looks like an HTML error page
8/// (e.g., 502/503 from a reverse proxy), extract the page title instead
9/// of dumping the full HTML into the error message.
10pub fn sanitize_html_error(text: &str) -> String {
11    let lower = text.to_lowercase();
12    if lower.contains("<!doctype html") || lower.contains("<html") {
13        // Try to extract the <title>...</title> content
14        if let Some(title_start) = text.find("<title>") {
15            let after_start = &text[title_start + "<title>".len()..];
16            if let Some(title_end) = after_start.find("</title>") {
17                let title = after_start[..title_end].trim().to_string();
18                if !title.is_empty() {
19                    return title;
20                }
21            }
22        }
23        // No title found; just drop the HTML entirely
24        String::new()
25    } else {
26        text.to_string()
27    }
28}
29
30/// Check if text starts with API error prefix
31pub fn starts_with_api_error_prefix(text: &str) -> bool {
32    text.starts_with(API_ERROR_MESSAGE_PREFIX)
33        || text.starts_with(&format!("Please run /login · {}", API_ERROR_MESSAGE_PREFIX))
34}
35
36/// Prompt too long error message
37pub const PROMPT_TOO_LONG_ERROR_MESSAGE: &str = "Prompt is too long";
38
39/// Check if a message is a prompt too long error
40pub fn is_prompt_too_long_message(msg: &ApiErrorMessage) -> bool {
41    if !msg.is_api_error_message {
42        return false;
43    }
44
45    let content = match &msg.content {
46        Some(c) => c,
47        None => return false,
48    };
49
50    // Check if content starts with the prompt too long message
51    content.starts_with(PROMPT_TOO_LONG_ERROR_MESSAGE)
52}
53
54/// Parse actual/limit token counts from a raw prompt-too-long API error
55/// message like "prompt is too long: 137500 tokens > 135000 maximum".
56/// The raw string may be wrapped in SDK prefixes or JSON envelopes, or
57/// have different casing (Vertex), so this is intentionally lenient.
58pub fn parse_prompt_too_long_token_counts(raw_message: &str) -> (Option<u64>, Option<u64>) {
59    // Regex: prompt is too long followed by any non-digits, then digits, "tokens", >, digits
60    // Using simple parsing instead of regex for no_std compatibility
61    let lower = raw_message.to_lowercase();
62
63    if !lower.contains("prompt is too long") {
64        return (None, None);
65    }
66
67    // Find all numbers in the message
68    let mut numbers: Vec<u64> = Vec::new();
69    let mut current_num = String::new();
70
71    for c in raw_message.chars() {
72        if c.is_ascii_digit() {
73            current_num.push(c);
74        } else if !current_num.is_empty() {
75            if let Ok(n) = current_num.parse() {
76                numbers.push(n);
77            }
78            current_num.clear();
79        }
80    }
81
82    // Don't forget the last number if string ends with digits
83    if !current_num.is_empty() {
84        if let Ok(n) = current_num.parse() {
85            numbers.push(n);
86        }
87    }
88
89    // We expect at least 2 numbers: actual and limit
90    if numbers.len() >= 2 {
91        // The larger number is likely the actual, smaller is limit
92        // But let's be smarter: look for ">" which indicates actual > limit
93        if let Some(gt_pos) = raw_message.find('>') {
94            let before_gt = &raw_message[..gt_pos];
95            let after_gt = &raw_message[gt_pos..];
96
97            // Extract numbers before and after >
98            let mut before_nums: Vec<u64> = Vec::new();
99            let mut after_nums: Vec<u64> = Vec::new();
100
101            let mut current = String::new();
102            for c in before_gt.chars().rev() {
103                if c.is_ascii_digit() {
104                    current.push(c);
105                } else if !current.is_empty() {
106                    if let Ok(n) = current.chars().rev().collect::<String>().parse() {
107                        before_nums.push(n);
108                    }
109                    current.clear();
110                }
111            }
112
113            current.clear();
114            for c in after_gt.chars() {
115                if c.is_ascii_digit() {
116                    current.push(c);
117                } else if !current.is_empty() {
118                    if let Ok(n) = current.parse() {
119                        after_nums.push(n);
120                    }
121                    current.clear();
122                }
123            }
124
125            if let (Some(actual), Some(limit)) = (before_nums.first(), after_nums.first()) {
126                return (Some(*actual), Some(*limit));
127            }
128        }
129    }
130
131    // Fallback: just take first two numbers if available
132    if numbers.len() >= 2 {
133        return (Some(numbers[0]), Some(numbers[1]));
134    }
135
136    (None, None)
137}
138
139/// Returns how many tokens over the limit a prompt-too-long error reports,
140/// or undefined if the message isn't PTL or its error_details are unparseable.
141pub fn get_prompt_too_long_token_gap(msg: &ApiErrorMessage) -> Option<i64> {
142    if !is_prompt_too_long_message(msg) {
143        return None;
144    }
145
146    let error_details = msg.error_details.as_ref()?;
147
148    let (actual_tokens, limit_tokens) = parse_prompt_too_long_token_counts(error_details);
149
150    let actual = actual_tokens?;
151    let limit = limit_tokens?;
152
153    let gap = actual as i64 - limit as i64;
154    if gap > 0 { Some(gap) } else { None }
155}
156
157/// Is this raw API error text a media-size rejection?
158/// Patterns MUST stay in sync with the getAssistantMessageFromError branches
159/// that populate error_details (~L523 PDF, ~L560 image, ~L573 many-image) and
160/// the classifyAPIError branches (~L929-946).
161pub fn is_media_size_error(raw: &str) -> bool {
162    let lower = raw.to_lowercase();
163
164    (lower.contains("image exceeds") && lower.contains("maximum"))
165        || (lower.contains("image dimensions exceed") && lower.contains("many-image"))
166        // Use original string for regex (case-sensitive), like TypeScript
167        || regex::Regex::new(r"maximum of \d+ PDF pages")
168            .map(|re| re.is_match(raw))
169            .unwrap_or(false)
170}
171
172/// Message-level predicate: is this assistant message a media-size rejection?
173pub fn is_media_size_error_message(msg: &ApiErrorMessage) -> bool {
174    msg.is_api_error_message
175        && msg
176            .error_details
177            .as_ref()
178            .map(|d| is_media_size_error(d))
179            .unwrap_or(false)
180}
181
182/// Credit balance too low error message
183pub const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE: &str = "Credit balance is too low";
184
185/// Invalid API key error message
186pub const INVALID_API_KEY_ERROR_MESSAGE: &str = "Not logged in · Please run /login";
187
188/// Invalid API key error message for external sources
189pub const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL: &str = "Invalid API key · Fix external API key";
190
191/// Organization disabled error message (env key with OAuth)
192pub const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH: &str = "Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead";
193
194/// Organization disabled error message (env key)
195pub const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY: &str = "Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable";
196
197/// Token revoked error message
198pub const TOKEN_REVOKED_ERROR_MESSAGE: &str = "OAuth token revoked · Please run /login";
199
200/// CCR auth error message
201pub const CCR_AUTH_ERROR_MESSAGE: &str =
202    "Authentication error · This may be a temporary network issue, please try again";
203
204/// Repeated 529 error message
205pub const REPEATED_529_ERROR_MESSAGE: &str = "Repeated 529 Overloaded errors";
206
207/// Custom off switch message
208pub const CUSTOM_OFF_SWITCH_MESSAGE: &str =
209    "Opus is experiencing high load, please use /model to switch to Sonnet";
210
211/// API timeout error message
212pub const API_TIMEOUT_ERROR_MESSAGE: &str = "Request timed out";
213
214/// Get PDF too large error message based on session type
215pub fn get_pdf_too_large_error_message(is_non_interactive: bool) -> String {
216    // In a real implementation, API_PDF_MAX_PAGES and PDF_TARGET_RAW_SIZE would be imported
217    let limits = "max 1000 pages, 32MB".to_string();
218
219    if is_non_interactive {
220        format!(
221            "PDF too large ({}). Try reading the file a different way (e.g., extract text with pdftotext).",
222            limits
223        )
224    } else {
225        format!(
226            "PDF too large ({}). Double press esc to go back and try again, or use pdftotext to convert to text first.",
227            limits
228        )
229    }
230}
231
232/// Get PDF password protected error message
233pub fn get_pdf_password_protected_error_message(is_non_interactive: bool) -> String {
234    if is_non_interactive {
235        "PDF is password protected. Try using a CLI tool to extract or convert the PDF.".to_string()
236    } else {
237        "PDF is password protected. Please double press esc to edit your message and try again."
238            .to_string()
239    }
240}
241
242/// Get PDF invalid error message
243pub fn get_pdf_invalid_error_message(is_non_interactive: bool) -> String {
244    if is_non_interactive {
245        "The PDF file was not valid. Try converting it to a text first (e.g., pdftotext)."
246            .to_string()
247    } else {
248        "The PDF file was not valid. Double press esc to go back and try again with a different file.".to_string()
249    }
250}
251
252/// Get image too large error message
253pub fn get_image_too_large_error_message(is_non_interactive: bool) -> String {
254    if is_non_interactive {
255        "Image was too large. Try resizing the image or using a different approach.".to_string()
256    } else {
257        "Image was too large. Double press esc to go back and try again with a smaller image."
258            .to_string()
259    }
260}
261
262/// Get request too large error message
263pub fn get_request_too_large_error_message(is_non_interactive: bool) -> String {
264    let limits = "max 32MB".to_string();
265
266    if is_non_interactive {
267        format!("Request too large ({}). Try with a smaller file.", limits)
268    } else {
269        format!(
270            "Request too large ({}). Double press esc to go back and try with a smaller file.",
271            limits
272        )
273    }
274}
275
276/// OAuth org not allowed error message
277pub const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE: &str =
278    "Your account does not have access to Claude Code. Please run /login.";
279
280/// Get token revoked error message
281pub fn get_token_revoked_error_message(is_non_interactive: bool) -> String {
282    if is_non_interactive {
283        "Your account does not have access to Claude. Please login again or contact your administrator."
284            .to_string()
285    } else {
286        TOKEN_REVOKED_ERROR_MESSAGE.to_string()
287    }
288}
289
290/// Get OAuth org not allowed error message
291pub fn get_oauth_org_not_allowed_error_message(is_non_interactive: bool) -> String {
292    if is_non_interactive {
293        "Your organization does not have access to Claude. Please login again or contact your administrator."
294            .to_string()
295    } else {
296        OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE.to_string()
297    }
298}
299
300/// API error types for classification
301#[derive(Debug, Clone, PartialEq)]
302#[allow(non_camel_case_types)]
303pub enum ApiErrorType {
304    aborted,
305    api_timeout,
306    repeated_529,
307    capacity_off_switch,
308    rate_limit,
309    server_overload,
310    prompt_too_long,
311    pdf_too_large,
312    pdf_password_protected,
313    image_too_large,
314    tool_use_mismatch,
315    unexpected_tool_result,
316    duplicate_tool_use_id,
317    invalid_model,
318    credit_balance_low,
319    invalid_api_key,
320    token_revoked,
321    oauth_org_not_allowed,
322    auth_error,
323    bedrock_model_access,
324    server_error,
325    client_error,
326    ssl_cert_error,
327    connection_error,
328    unknown,
329}
330
331/// SDK assistant message error types
332#[derive(Debug, Clone, PartialEq)]
333#[allow(non_camel_case_types)]
334pub enum SDKAssistantMessageError {
335    rate_limit,
336    authentication_failed,
337    server_error,
338    unknown,
339}
340
341/// Assistant message structure for API errors
342#[derive(Debug, Clone)]
343pub struct ApiErrorMessage {
344    pub is_api_error_message: bool,
345    pub content: Option<String>,
346    pub error: Option<String>,
347    pub error_details: Option<String>,
348}
349
350impl Default for ApiErrorMessage {
351    fn default() -> Self {
352        Self {
353            is_api_error_message: true,
354            content: Some(API_ERROR_MESSAGE_PREFIX.to_string()),
355            error: Some("unknown".to_string()),
356            error_details: None,
357        }
358    }
359}
360
361/// Create an assistant API error message
362pub fn create_assistant_api_error_message(content: &str) -> ApiErrorMessage {
363    ApiErrorMessage {
364        is_api_error_message: true,
365        content: Some(content.to_string()),
366        error: Some("unknown".to_string()),
367        error_details: None,
368    }
369}
370
371/// Create an assistant API error message with optional parameters
372pub fn create_assistant_api_error_message_with_options(
373    content: &str,
374    error: Option<&str>,
375    error_details: Option<&str>,
376) -> ApiErrorMessage {
377    ApiErrorMessage {
378        is_api_error_message: true,
379        content: Some(content.to_string()),
380        error: error.map(String::from),
381        error_details: error_details.map(String::from),
382    }
383}
384
385/// Check if we're in CCR (Claude Code Remote) mode.
386/// In CCR mode, auth is handled via JWTs provided by the infrastructure,
387/// not via /login. Transient auth errors should suggest retrying, not logging in.
388/// Note: This is a placeholder - actual implementation would check environment
389pub fn is_ccr_mode() -> bool {
390    // Would check process.env.CLAUDE_CODE_REMOTE
391    false
392}
393
394/// Type guard to check if a value is a valid API message response
395pub fn is_valid_api_message(value: &serde_json::Value) -> bool {
396    value.get("content").is_some()
397        && value.get("model").is_some()
398        && value.get("usage").is_some()
399        && value["content"].is_array()
400        && value["model"].is_string()
401        && value["usage"].is_object()
402}
403
404/// Lower-level error that AWS can return
405#[derive(Debug, Clone, Default)]
406pub struct AmazonError {
407    pub output: Option<AmazonOutput>,
408    pub version: Option<String>,
409}
410
411#[derive(Debug, Clone, Default)]
412pub struct AmazonOutput {
413    pub type_: Option<String>,
414}
415
416impl AmazonError {
417    pub fn new() -> Self {
418        Self::default()
419    }
420
421    pub fn from_json(value: &serde_json::Value) -> Option<Self> {
422        let output = value.get("Output")?;
423        let output_type = output
424            .get("__type")
425            .and_then(|v| v.as_str())
426            .map(String::from);
427
428        Some(AmazonError {
429            output: Some(AmazonOutput { type_: output_type }),
430            version: value
431                .get("Version")
432                .and_then(|v| v.as_str())
433                .map(String::from),
434        })
435    }
436}
437
438/// Given a response that doesn't look quite right, see if it contains any known error types
439pub fn extract_unknown_error_format(value: &serde_json::Value) -> Option<String> {
440    // Check if value is a valid object first
441    if !value.is_object() {
442        return None;
443    }
444
445    // Amazon Bedrock routing errors
446    if let Some(output) = value.get("Output") {
447        if let Some(output_type) = output.get("__type").and_then(|v| v.as_str()) {
448            return Some(output_type.to_string());
449        }
450    }
451
452    None
453}
454
455/// Classifies an API error into a specific error type for analytics tracking.
456/// Returns a standardized error type string suitable for tagging.
457pub fn classify_api_error(error_message: &str, status: Option<u16>) -> ApiErrorType {
458    let lower = error_message.to_lowercase();
459
460    // Aborted requests
461    if error_message == "Request was aborted." {
462        return ApiErrorType::aborted;
463    }
464
465    // Timeout errors
466    if lower.contains("timeout") {
467        return ApiErrorType::api_timeout;
468    }
469
470    // Check for repeated 529 errors
471    if error_message.contains(REPEATED_529_ERROR_MESSAGE) {
472        return ApiErrorType::repeated_529;
473    }
474
475    // Check for emergency capacity off switch
476    if error_message.contains(CUSTOM_OFF_SWITCH_MESSAGE) {
477        return ApiErrorType::capacity_off_switch;
478    }
479
480    // Rate limiting
481    if status == Some(429) {
482        return ApiErrorType::rate_limit;
483    }
484
485    // Server overload (529)
486    if status == Some(529) || error_message.contains(r#""type":"overloaded_error""#) {
487        return ApiErrorType::server_overload;
488    }
489
490    // Prompt/content size errors
491    if lower.contains(&PROMPT_TOO_LONG_ERROR_MESSAGE.to_lowercase()) {
492        return ApiErrorType::prompt_too_long;
493    }
494
495    // PDF errors
496    if is_media_size_error(error_message) && error_message.to_lowercase().contains("pdf") {
497        if error_message.to_lowercase().contains("password") {
498            return ApiErrorType::pdf_password_protected;
499        }
500        return ApiErrorType::pdf_too_large;
501    }
502
503    // Image size errors
504    if status == Some(400)
505        && lower.contains("image")
506        && lower.contains("exceeds")
507        && lower.contains("maximum")
508    {
509        return ApiErrorType::image_too_large;
510    }
511
512    // Many-image dimension errors
513    if status == Some(400)
514        && lower.contains("image dimensions exceed")
515        && lower.contains("many-image")
516    {
517        return ApiErrorType::image_too_large;
518    }
519
520    // Tool use errors (400)
521    if status == Some(400)
522        && error_message.contains("`tool_use` ids were found without `tool_result`")
523    {
524        return ApiErrorType::tool_use_mismatch;
525    }
526
527    if status == Some(400)
528        && error_message.contains("unexpected `tool_use_id` found in `tool_result`")
529    {
530        return ApiErrorType::unexpected_tool_result;
531    }
532
533    if status == Some(400) && error_message.contains("`tool_use` ids must be unique") {
534        return ApiErrorType::duplicate_tool_use_id;
535    }
536
537    // Invalid model errors (400)
538    if status == Some(400) && lower.contains("invalid model name") {
539        return ApiErrorType::invalid_model;
540    }
541
542    // Credit/billing errors
543    if lower.contains(&CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE.to_lowercase()) {
544        return ApiErrorType::credit_balance_low;
545    }
546
547    // Authentication errors
548    if lower.contains("x-api-key") {
549        return ApiErrorType::invalid_api_key;
550    }
551
552    if status == Some(403) && error_message.contains("OAuth token has been revoked") {
553        return ApiErrorType::token_revoked;
554    }
555
556    if (status == Some(401) || status == Some(403))
557        && error_message
558            .contains("OAuth authentication is currently not allowed for this organization")
559    {
560        return ApiErrorType::oauth_org_not_allowed;
561    }
562
563    // Generic auth errors
564    if status == Some(401) || status == Some(403) {
565        return ApiErrorType::auth_error;
566    }
567
568    // Bedrock-specific errors
569    // if is_env_truthy(process.env.CLAUDE_CODE_USE_BEDROCK) && lower.contains("model id") {
570    //     return "bedrock_model_access";
571    // }
572
573    // Status code based fallbacks
574    if let Some(s) = status {
575        if s >= 500 {
576            return ApiErrorType::server_error;
577        }
578        if s >= 400 {
579            return ApiErrorType::client_error;
580        }
581    }
582
583    // Connection errors
584    if lower.contains("connection") || lower.contains("ssl") || lower.contains("tls") {
585        if lower.contains("ssl") || lower.contains("certificate") {
586            return ApiErrorType::ssl_cert_error;
587        }
588        return ApiErrorType::connection_error;
589    }
590
591    ApiErrorType::unknown
592}
593
594/// Categorize retryable API errors
595pub fn categorize_retryable_api_error(status: u16, message: &str) -> SDKAssistantMessageError {
596    if status == 529 || message.contains(r#""type":"overloaded_error""#) {
597        return SDKAssistantMessageError::rate_limit;
598    }
599    if status == 429 {
600        return SDKAssistantMessageError::rate_limit;
601    }
602    if status == 401 || status == 403 {
603        return SDKAssistantMessageError::authentication_failed;
604    }
605    if status >= 408 {
606        return SDKAssistantMessageError::server_error;
607    }
608    SDKAssistantMessageError::unknown
609}
610
611/// Get error message if refusal
612pub fn get_error_message_if_refusal(
613    stop_reason: Option<&str>,
614    model: &str,
615    is_non_interactive: bool,
616) -> Option<ApiErrorMessage> {
617    if stop_reason != Some("refusal") {
618        return None;
619    }
620
621    // In a real implementation, this would log an event
622    // logEvent('tengu_refusal_api_response', {});
623
624    let base_message = if is_non_interactive {
625        format!(
626            "{}: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Try rephrasing the request or attempting a different approach.",
627            API_ERROR_MESSAGE_PREFIX
628        )
629    } else {
630        format!(
631            "{}: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Please double press esc to edit your last message or start a new session for Claude Code to assist with a different task.",
632            API_ERROR_MESSAGE_PREFIX
633        )
634    };
635
636    let model_suggestion = if model != "claude-sonnet-4-20250514" {
637        " If you are seeing this refusal repeatedly, try running /model claude-sonnet-4-20250514 to switch models."
638    } else {
639        ""
640    };
641
642    Some(create_assistant_api_error_message_with_options(
643        &(base_message + model_suggestion),
644        Some("invalid_request"),
645        None,
646    ))
647}
648
649/// Constant for no response requested
650pub const NO_RESPONSE_REQUESTED: &str = "NO_RESPONSE_REQUESTED";
651
652/// Map a raw error message to a rich `ApiErrorMessage` with structured content,
653/// error type, and optional error_details. Mirrors the TypeScript
654/// `getAssistantMessageFromError()` function.
655pub fn error_to_api_message(error_msg: &str, status: Option<u16>) -> ApiErrorMessage {
656    let lower = error_msg.to_lowercase();
657
658    // Aborted requests
659    if error_msg == "Request was aborted." || error_msg == "User aborted the request" {
660        return create_assistant_api_error_message_with_options(
661            "Request was aborted",
662            Some("aborted"),
663            Some(error_msg),
664        );
665    }
666
667    // Timeout errors
668    if lower.contains("timeout") || lower.contains("timed out") {
669        return create_assistant_api_error_message_with_options(
670            API_TIMEOUT_ERROR_MESSAGE,
671            Some("unknown"),
672            Some(error_msg),
673        );
674    }
675
676    // Repeated 529 errors
677    if error_msg.contains(REPEATED_529_ERROR_MESSAGE) {
678        return create_assistant_api_error_message_with_options(
679            REPEATED_529_ERROR_MESSAGE,
680            Some("server_overload"),
681            Some(error_msg),
682        );
683    }
684
685    // Rate limiting (429)
686    if status == Some(429) || lower.contains("rate_limit") || lower.contains("rate limit") {
687        return create_assistant_api_error_message_with_options(
688            "Rate limit exceeded. Please try again shortly.",
689            Some("rate_limit"),
690            Some(error_msg),
691        );
692    }
693
694    // Server overload (529)
695    if status == Some(529) || lower.contains("overloaded") {
696        return create_assistant_api_error_message_with_options(
697            "Server is overloaded. Retrying...",
698            Some("server_overload"),
699            Some(error_msg),
700        );
701    }
702
703    // Prompt too long (413)
704    if lower.contains(&PROMPT_TOO_LONG_ERROR_MESSAGE.to_lowercase())
705        || lower.contains("prompt is too long")
706        || (status == Some(413) && lower.contains("too long"))
707    {
708        return create_assistant_api_error_message_with_options(
709            PROMPT_TOO_LONG_ERROR_MESSAGE,
710            Some("invalid_request"),
711            Some(error_msg),
712        );
713    }
714
715    // PDF errors
716    if is_media_size_error(error_msg) && lower.contains("pdf") {
717        if lower.contains("password") {
718            return create_assistant_api_error_message_with_options(
719                &get_pdf_password_protected_error_message(false),
720                Some("invalid_request"),
721                Some(error_msg),
722            );
723        }
724        return create_assistant_api_error_message_with_options(
725            &get_pdf_too_large_error_message(false),
726            Some("invalid_request"),
727            Some(error_msg),
728        );
729    }
730
731    // Image size errors
732    if (status == Some(400) && lower.contains("image") && lower.contains("exceeds") && lower.contains("maximum"))
733        || (lower.contains("image exceeds") && lower.contains("maximum"))
734    {
735        return create_assistant_api_error_message_with_options(
736            &get_image_too_large_error_message(false),
737            Some("invalid_request"),
738            Some(error_msg),
739        );
740    }
741
742    // Request too large (413)
743    if status == Some(413) {
744        return create_assistant_api_error_message_with_options(
745            &get_request_too_large_error_message(false),
746            Some("invalid_request"),
747            Some(error_msg),
748        );
749    }
750
751    // Tool use mismatch
752    if status == Some(400) && error_msg.contains("`tool_use` ids were found without `tool_result`") {
753        return create_assistant_api_error_message_with_options(
754            &format!("{}: Tool use mismatch. Try /rewind to fix.", API_ERROR_MESSAGE_PREFIX),
755            Some("invalid_request"),
756            Some(error_msg),
757        );
758    }
759
760    // Duplicate tool use ID
761    if status == Some(400) && error_msg.contains("`tool_use` ids must be unique") {
762        return create_assistant_api_error_message_with_options(
763            &format!("{}: Duplicate tool use ID. Try /rewind to fix.", API_ERROR_MESSAGE_PREFIX),
764            Some("invalid_request"),
765            Some(error_msg),
766        );
767    }
768
769    // Invalid model
770    if status == Some(400) && lower.contains("invalid model") {
771        return create_assistant_api_error_message_with_options(
772            "Model is not available. Try /model to switch.",
773            Some("invalid_request"),
774            Some(error_msg),
775        );
776    }
777
778    // Credit balance too low
779    if lower.contains(&CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE.to_lowercase()) {
780        return create_assistant_api_error_message_with_options(
781            CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE,
782            Some("billing_error"),
783            Some(error_msg),
784        );
785    }
786
787    // Authentication errors
788    if lower.contains("x-api-key") || lower.contains("api key") && status == Some(401) {
789        return create_assistant_api_error_message_with_options(
790            INVALID_API_KEY_ERROR_MESSAGE,
791            Some("authentication_failed"),
792            Some(error_msg),
793        );
794    }
795
796    // Token revoked
797    if status == Some(403) && error_msg.contains("OAuth token has been revoked") {
798        return create_assistant_api_error_message_with_options(
799            TOKEN_REVOKED_ERROR_MESSAGE,
800            Some("authentication_failed"),
801            Some(error_msg),
802        );
803    }
804
805    // OAuth org not allowed
806    if (status == Some(401) || status == Some(403))
807        && error_msg.contains("OAuth authentication is currently not allowed for this organization")
808    {
809        return create_assistant_api_error_message_with_options(
810            OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE,
811            Some("authentication_failed"),
812            Some(error_msg),
813        );
814    }
815
816    // Generic auth errors
817    if status == Some(401) || status == Some(403) {
818        return create_assistant_api_error_message_with_options(
819            "Authentication failed.",
820            Some("authentication_failed"),
821            Some(error_msg),
822        );
823    }
824
825    // Connection errors
826    if lower.contains("connection") || lower.contains("ssl") || lower.contains("tls") {
827        return create_assistant_api_error_message_with_options(
828            "Connection error. Check your network and try again.",
829            Some("connection_error"),
830            Some(error_msg),
831        );
832    }
833
834    // Generic fallback — use the raw message prefixed with "API Error"
835    if lower.starts_with("api error") {
836        create_assistant_api_error_message_with_options(error_msg, Some("unknown"), Some(error_msg))
837    } else {
838        create_assistant_api_error_message_with_options(
839            &format!("{}: {}", API_ERROR_MESSAGE_PREFIX, error_msg),
840            Some("unknown"),
841            Some(error_msg),
842        )
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn test_starts_with_api_error_prefix() {
852        assert!(starts_with_api_error_prefix(
853            "API Error: something went wrong"
854        ));
855        assert!(starts_with_api_error_prefix(
856            "Please run /login · API Error: test"
857        ));
858        assert!(!starts_with_api_error_prefix("Something else"));
859    }
860
861    #[test]
862    fn test_is_terminal_task_status() {
863        assert!(!is_media_size_error("some random error"));
864        assert!(is_media_size_error("image exceeds 5 MB maximum"));
865        assert!(is_media_size_error(
866            "image dimensions exceed limit for many-image"
867        ));
868        assert!(is_media_size_error("maximum of 1000 PDF pages"));
869    }
870
871    #[test]
872    fn test_classify_api_error() {
873        assert_eq!(
874            classify_api_error("Request was aborted.", None),
875            ApiErrorType::aborted
876        );
877        assert_eq!(
878            classify_api_error("timeout error", None),
879            ApiErrorType::api_timeout
880        );
881        assert_eq!(
882            classify_api_error("rate limit", Some(429)),
883            ApiErrorType::rate_limit
884        );
885        assert_eq!(
886            classify_api_error("server overloaded", Some(529)),
887            ApiErrorType::server_overload
888        );
889        assert_eq!(
890            classify_api_error("Prompt is too long", None),
891            ApiErrorType::prompt_too_long
892        );
893    }
894
895    #[test]
896    fn test_categorize_retryable_api_error() {
897        assert_eq!(
898            categorize_retryable_api_error(529, "overloaded"),
899            SDKAssistantMessageError::rate_limit
900        );
901        assert_eq!(
902            categorize_retryable_api_error(429, "rate limit"),
903            SDKAssistantMessageError::rate_limit
904        );
905        assert_eq!(
906            categorize_retryable_api_error(401, "unauthorized"),
907            SDKAssistantMessageError::authentication_failed
908        );
909        assert_eq!(
910            categorize_retryable_api_error(500, "server error"),
911            SDKAssistantMessageError::server_error
912        );
913    }
914
915    #[test]
916    fn test_sanitize_html_error() {
917        // HTML with title
918        let html = "<html><head><title>502 Bad Gateway</title></head><body><p>error</p></body></html>";
919        assert_eq!(sanitize_html_error(html), "502 Bad Gateway");
920
921        // HTML without title
922        let html_no_title = "<html><body><p>error</p></body></html>";
923        assert_eq!(sanitize_html_error(html_no_title), "");
924
925        // DOCTYPE HTML
926        let doctype = "<!DOCTYPE html><html><head><title>503 Service Unavailable</title></head>";
927        assert_eq!(sanitize_html_error(doctype), "503 Service Unavailable");
928
929        // Plain text error (not HTML) — returned as-is
930        let plain = "{\"error\":{\"message\":\"rate limited\"}}";
931        assert_eq!(sanitize_html_error(plain), "{\"error\":{\"message\":\"rate limited\"}}");
932
933        // Empty string
934        assert_eq!(sanitize_html_error(""), "");
935    }
936
937    #[test]
938    fn test_parse_prompt_too_long_token_counts() {
939        let (actual, limit) = parse_prompt_too_long_token_counts(
940            "prompt is too long: 137500 tokens > 135000 maximum",
941        );
942        assert_eq!(actual, Some(137500));
943        assert_eq!(limit, Some(135000));
944    }
945}