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/// Check if text starts with API error prefix
8pub fn starts_with_api_error_prefix(text: &str) -> bool {
9    text.starts_with(API_ERROR_MESSAGE_PREFIX)
10        || text.starts_with(&format!("Please run /login · {}", API_ERROR_MESSAGE_PREFIX))
11}
12
13/// Prompt too long error message
14pub const PROMPT_TOO_LONG_ERROR_MESSAGE: &str = "Prompt is too long";
15
16/// Check if a message is a prompt too long error
17pub fn is_prompt_too_long_message(msg: &ApiErrorMessage) -> bool {
18    if !msg.is_api_error_message {
19        return false;
20    }
21
22    let content = match &msg.content {
23        Some(c) => c,
24        None => return false,
25    };
26
27    // Check if content starts with the prompt too long message
28    content.starts_with(PROMPT_TOO_LONG_ERROR_MESSAGE)
29}
30
31/// Parse actual/limit token counts from a raw prompt-too-long API error
32/// message like "prompt is too long: 137500 tokens > 135000 maximum".
33/// The raw string may be wrapped in SDK prefixes or JSON envelopes, or
34/// have different casing (Vertex), so this is intentionally lenient.
35pub fn parse_prompt_too_long_token_counts(raw_message: &str) -> (Option<u64>, Option<u64>) {
36    // Regex: prompt is too long followed by any non-digits, then digits, "tokens", >, digits
37    // Using simple parsing instead of regex for no_std compatibility
38    let lower = raw_message.to_lowercase();
39
40    if !lower.contains("prompt is too long") {
41        return (None, None);
42    }
43
44    // Find all numbers in the message
45    let mut numbers: Vec<u64> = Vec::new();
46    let mut current_num = String::new();
47
48    for c in raw_message.chars() {
49        if c.is_ascii_digit() {
50            current_num.push(c);
51        } else if !current_num.is_empty() {
52            if let Ok(n) = current_num.parse() {
53                numbers.push(n);
54            }
55            current_num.clear();
56        }
57    }
58
59    // Don't forget the last number if string ends with digits
60    if !current_num.is_empty() {
61        if let Ok(n) = current_num.parse() {
62            numbers.push(n);
63        }
64    }
65
66    // We expect at least 2 numbers: actual and limit
67    if numbers.len() >= 2 {
68        // The larger number is likely the actual, smaller is limit
69        // But let's be smarter: look for ">" which indicates actual > limit
70        if let Some(gt_pos) = raw_message.find('>') {
71            let before_gt = &raw_message[..gt_pos];
72            let after_gt = &raw_message[gt_pos..];
73
74            // Extract numbers before and after >
75            let mut before_nums: Vec<u64> = Vec::new();
76            let mut after_nums: Vec<u64> = Vec::new();
77
78            let mut current = String::new();
79            for c in before_gt.chars().rev() {
80                if c.is_ascii_digit() {
81                    current.push(c);
82                } else if !current.is_empty() {
83                    if let Ok(n) = current.chars().rev().collect::<String>().parse() {
84                        before_nums.push(n);
85                    }
86                    current.clear();
87                }
88            }
89
90            current.clear();
91            for c in after_gt.chars() {
92                if c.is_ascii_digit() {
93                    current.push(c);
94                } else if !current.is_empty() {
95                    if let Ok(n) = current.parse() {
96                        after_nums.push(n);
97                    }
98                    current.clear();
99                }
100            }
101
102            if let (Some(actual), Some(limit)) = (before_nums.first(), after_nums.first()) {
103                return (Some(*actual), Some(*limit));
104            }
105        }
106    }
107
108    // Fallback: just take first two numbers if available
109    if numbers.len() >= 2 {
110        return (Some(numbers[0]), Some(numbers[1]));
111    }
112
113    (None, None)
114}
115
116/// Returns how many tokens over the limit a prompt-too-long error reports,
117/// or undefined if the message isn't PTL or its error_details are unparseable.
118pub fn get_prompt_too_long_token_gap(msg: &ApiErrorMessage) -> Option<i64> {
119    if !is_prompt_too_long_message(msg) {
120        return None;
121    }
122
123    let error_details = msg.error_details.as_ref()?;
124
125    let (actual_tokens, limit_tokens) = parse_prompt_too_long_token_counts(error_details);
126
127    let actual = actual_tokens?;
128    let limit = limit_tokens?;
129
130    let gap = actual as i64 - limit as i64;
131    if gap > 0 {
132        Some(gap)
133    } else {
134        None
135    }
136}
137
138/// Is this raw API error text a media-size rejection?
139/// Patterns MUST stay in sync with the getAssistantMessageFromError branches
140/// that populate error_details (~L523 PDF, ~L560 image, ~L573 many-image) and
141/// the classifyAPIError branches (~L929-946).
142pub fn is_media_size_error(raw: &str) -> bool {
143    let lower = raw.to_lowercase();
144
145    (lower.contains("image exceeds") && lower.contains("maximum"))
146        || (lower.contains("image dimensions exceed") && lower.contains("many-image"))
147        // Use original string for regex (case-sensitive), like TypeScript
148        || regex::Regex::new(r"maximum of \d+ PDF pages")
149            .map(|re| re.is_match(raw))
150            .unwrap_or(false)
151}
152
153/// Message-level predicate: is this assistant message a media-size rejection?
154pub fn is_media_size_error_message(msg: &ApiErrorMessage) -> bool {
155    msg.is_api_error_message
156        && msg
157            .error_details
158            .as_ref()
159            .map(|d| is_media_size_error(d))
160            .unwrap_or(false)
161}
162
163/// Credit balance too low error message
164pub const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE: &str = "Credit balance is too low";
165
166/// Invalid API key error message
167pub const INVALID_API_KEY_ERROR_MESSAGE: &str = "Not logged in · Please run /login";
168
169/// Invalid API key error message for external sources
170pub const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL: &str = "Invalid API key · Fix external API key";
171
172/// Organization disabled error message (env key with OAuth)
173pub const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH: &str =
174    "Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead";
175
176/// Organization disabled error message (env key)
177pub const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY: &str =
178    "Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable";
179
180/// Token revoked error message
181pub const TOKEN_REVOKED_ERROR_MESSAGE: &str = "OAuth token revoked · Please run /login";
182
183/// CCR auth error message
184pub const CCR_AUTH_ERROR_MESSAGE: &str =
185    "Authentication error · This may be a temporary network issue, please try again";
186
187/// Repeated 529 error message
188pub const REPEATED_529_ERROR_MESSAGE: &str = "Repeated 529 Overloaded errors";
189
190/// Custom off switch message
191pub const CUSTOM_OFF_SWITCH_MESSAGE: &str =
192    "Opus is experiencing high load, please use /model to switch to Sonnet";
193
194/// API timeout error message
195pub const API_TIMEOUT_ERROR_MESSAGE: &str = "Request timed out";
196
197/// Get PDF too large error message based on session type
198pub fn get_pdf_too_large_error_message(is_non_interactive: bool) -> String {
199    // In a real implementation, API_PDF_MAX_PAGES and PDF_TARGET_RAW_SIZE would be imported
200    let limits = "max 1000 pages, 32MB".to_string();
201
202    if is_non_interactive {
203        format!(
204            "PDF too large ({}). Try reading the file a different way (e.g., extract text with pdftotext).",
205            limits
206        )
207    } else {
208        format!(
209            "PDF too large ({}). Double press esc to go back and try again, or use pdftotext to convert to text first.",
210            limits
211        )
212    }
213}
214
215/// Get PDF password protected error message
216pub fn get_pdf_password_protected_error_message(is_non_interactive: bool) -> String {
217    if is_non_interactive {
218        "PDF is password protected. Try using a CLI tool to extract or convert the PDF.".to_string()
219    } else {
220        "PDF is password protected. Please double press esc to edit your message and try again."
221            .to_string()
222    }
223}
224
225/// Get PDF invalid error message
226pub fn get_pdf_invalid_error_message(is_non_interactive: bool) -> String {
227    if is_non_interactive {
228        "The PDF file was not valid. Try converting it to a text first (e.g., pdftotext)."
229            .to_string()
230    } else {
231        "The PDF file was not valid. Double press esc to go back and try again with a different file.".to_string()
232    }
233}
234
235/// Get image too large error message
236pub fn get_image_too_large_error_message(is_non_interactive: bool) -> String {
237    if is_non_interactive {
238        "Image was too large. Try resizing the image or using a different approach.".to_string()
239    } else {
240        "Image was too large. Double press esc to go back and try again with a smaller image."
241            .to_string()
242    }
243}
244
245/// Get request too large error message
246pub fn get_request_too_large_error_message(is_non_interactive: bool) -> String {
247    let limits = "max 32MB".to_string();
248
249    if is_non_interactive {
250        format!("Request too large ({}). Try with a smaller file.", limits)
251    } else {
252        format!(
253            "Request too large ({}). Double press esc to go back and try with a smaller file.",
254            limits
255        )
256    }
257}
258
259/// OAuth org not allowed error message
260pub const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE: &str =
261    "Your account does not have access to Claude Code. Please run /login.";
262
263/// Get token revoked error message
264pub fn get_token_revoked_error_message(is_non_interactive: bool) -> String {
265    if is_non_interactive {
266        "Your account does not have access to Claude. Please login again or contact your administrator."
267            .to_string()
268    } else {
269        TOKEN_REVOKED_ERROR_MESSAGE.to_string()
270    }
271}
272
273/// Get OAuth org not allowed error message
274pub fn get_oauth_org_not_allowed_error_message(is_non_interactive: bool) -> String {
275    if is_non_interactive {
276        "Your organization does not have access to Claude. Please login again or contact your administrator."
277            .to_string()
278    } else {
279        OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE.to_string()
280    }
281}
282
283/// API error types for classification
284#[derive(Debug, Clone, PartialEq)]
285#[allow(non_camel_case_types)]
286pub enum ApiErrorType {
287    aborted,
288    api_timeout,
289    repeated_529,
290    capacity_off_switch,
291    rate_limit,
292    server_overload,
293    prompt_too_long,
294    pdf_too_large,
295    pdf_password_protected,
296    image_too_large,
297    tool_use_mismatch,
298    unexpected_tool_result,
299    duplicate_tool_use_id,
300    invalid_model,
301    credit_balance_low,
302    invalid_api_key,
303    token_revoked,
304    oauth_org_not_allowed,
305    auth_error,
306    bedrock_model_access,
307    server_error,
308    client_error,
309    ssl_cert_error,
310    connection_error,
311    unknown,
312}
313
314/// SDK assistant message error types
315#[derive(Debug, Clone, PartialEq)]
316#[allow(non_camel_case_types)]
317pub enum SDKAssistantMessageError {
318    rate_limit,
319    authentication_failed,
320    server_error,
321    unknown,
322}
323
324/// Assistant message structure for API errors
325#[derive(Debug, Clone)]
326pub struct ApiErrorMessage {
327    pub is_api_error_message: bool,
328    pub content: Option<String>,
329    pub error: Option<String>,
330    pub error_details: Option<String>,
331}
332
333impl Default for ApiErrorMessage {
334    fn default() -> Self {
335        Self {
336            is_api_error_message: true,
337            content: Some(API_ERROR_MESSAGE_PREFIX.to_string()),
338            error: Some("unknown".to_string()),
339            error_details: None,
340        }
341    }
342}
343
344/// Create an assistant API error message
345pub fn create_assistant_api_error_message(content: &str) -> ApiErrorMessage {
346    ApiErrorMessage {
347        is_api_error_message: true,
348        content: Some(content.to_string()),
349        error: Some("unknown".to_string()),
350        error_details: None,
351    }
352}
353
354/// Create an assistant API error message with optional parameters
355pub fn create_assistant_api_error_message_with_options(
356    content: &str,
357    error: Option<&str>,
358    error_details: Option<&str>,
359) -> ApiErrorMessage {
360    ApiErrorMessage {
361        is_api_error_message: true,
362        content: Some(content.to_string()),
363        error: error.map(String::from),
364        error_details: error_details.map(String::from),
365    }
366}
367
368/// Check if we're in CCR (Claude Code Remote) mode.
369/// In CCR mode, auth is handled via JWTs provided by the infrastructure,
370/// not via /login. Transient auth errors should suggest retrying, not logging in.
371/// Note: This is a placeholder - actual implementation would check environment
372pub fn is_ccr_mode() -> bool {
373    // Would check process.env.CLAUDE_CODE_REMOTE
374    false
375}
376
377/// Type guard to check if a value is a valid API message response
378pub fn is_valid_api_message(value: &serde_json::Value) -> bool {
379    value.get("content").is_some()
380        && value.get("model").is_some()
381        && value.get("usage").is_some()
382        && value["content"].is_array()
383        && value["model"].is_string()
384        && value["usage"].is_object()
385}
386
387/// Lower-level error that AWS can return
388#[derive(Debug, Clone, Default)]
389pub struct AmazonError {
390    pub output: Option<AmazonOutput>,
391    pub version: Option<String>,
392}
393
394#[derive(Debug, Clone, Default)]
395pub struct AmazonOutput {
396    pub type_: Option<String>,
397}
398
399impl AmazonError {
400    pub fn new() -> Self {
401        Self::default()
402    }
403
404    pub fn from_json(value: &serde_json::Value) -> Option<Self> {
405        let output = value.get("Output")?;
406        let output_type = output
407            .get("__type")
408            .and_then(|v| v.as_str())
409            .map(String::from);
410
411        Some(AmazonError {
412            output: Some(AmazonOutput { type_: output_type }),
413            version: value
414                .get("Version")
415                .and_then(|v| v.as_str())
416                .map(String::from),
417        })
418    }
419}
420
421/// Given a response that doesn't look quite right, see if it contains any known error types
422pub fn extract_unknown_error_format(value: &serde_json::Value) -> Option<String> {
423    // Check if value is a valid object first
424    if !value.is_object() {
425        return None;
426    }
427
428    // Amazon Bedrock routing errors
429    if let Some(output) = value.get("Output") {
430        if let Some(output_type) = output.get("__type").and_then(|v| v.as_str()) {
431            return Some(output_type.to_string());
432        }
433    }
434
435    None
436}
437
438/// Classifies an API error into a specific error type for analytics tracking.
439/// Returns a standardized error type string suitable for tagging.
440pub fn classify_api_error(error_message: &str, status: Option<u16>) -> ApiErrorType {
441    let lower = error_message.to_lowercase();
442
443    // Aborted requests
444    if error_message == "Request was aborted." {
445        return ApiErrorType::aborted;
446    }
447
448    // Timeout errors
449    if lower.contains("timeout") {
450        return ApiErrorType::api_timeout;
451    }
452
453    // Check for repeated 529 errors
454    if error_message.contains(REPEATED_529_ERROR_MESSAGE) {
455        return ApiErrorType::repeated_529;
456    }
457
458    // Check for emergency capacity off switch
459    if error_message.contains(CUSTOM_OFF_SWITCH_MESSAGE) {
460        return ApiErrorType::capacity_off_switch;
461    }
462
463    // Rate limiting
464    if status == Some(429) {
465        return ApiErrorType::rate_limit;
466    }
467
468    // Server overload (529)
469    if status == Some(529) || error_message.contains(r#""type":"overloaded_error""#) {
470        return ApiErrorType::server_overload;
471    }
472
473    // Prompt/content size errors
474    if lower.contains(&PROMPT_TOO_LONG_ERROR_MESSAGE.to_lowercase()) {
475        return ApiErrorType::prompt_too_long;
476    }
477
478    // PDF errors
479    if is_media_size_error(error_message) && error_message.to_lowercase().contains("pdf") {
480        if error_message.to_lowercase().contains("password") {
481            return ApiErrorType::pdf_password_protected;
482        }
483        return ApiErrorType::pdf_too_large;
484    }
485
486    // Image size errors
487    if status == Some(400)
488        && lower.contains("image")
489        && lower.contains("exceeds")
490        && lower.contains("maximum")
491    {
492        return ApiErrorType::image_too_large;
493    }
494
495    // Many-image dimension errors
496    if status == Some(400)
497        && lower.contains("image dimensions exceed")
498        && lower.contains("many-image")
499    {
500        return ApiErrorType::image_too_large;
501    }
502
503    // Tool use errors (400)
504    if status == Some(400)
505        && error_message.contains("`tool_use` ids were found without `tool_result`")
506    {
507        return ApiErrorType::tool_use_mismatch;
508    }
509
510    if status == Some(400)
511        && error_message.contains("unexpected `tool_use_id` found in `tool_result`")
512    {
513        return ApiErrorType::unexpected_tool_result;
514    }
515
516    if status == Some(400) && error_message.contains("`tool_use` ids must be unique") {
517        return ApiErrorType::duplicate_tool_use_id;
518    }
519
520    // Invalid model errors (400)
521    if status == Some(400) && lower.contains("invalid model name") {
522        return ApiErrorType::invalid_model;
523    }
524
525    // Credit/billing errors
526    if lower.contains(&CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE.to_lowercase()) {
527        return ApiErrorType::credit_balance_low;
528    }
529
530    // Authentication errors
531    if lower.contains("x-api-key") {
532        return ApiErrorType::invalid_api_key;
533    }
534
535    if status == Some(403) && error_message.contains("OAuth token has been revoked") {
536        return ApiErrorType::token_revoked;
537    }
538
539    if (status == Some(401) || status == Some(403))
540        && error_message
541            .contains("OAuth authentication is currently not allowed for this organization")
542    {
543        return ApiErrorType::oauth_org_not_allowed;
544    }
545
546    // Generic auth errors
547    if status == Some(401) || status == Some(403) {
548        return ApiErrorType::auth_error;
549    }
550
551    // Bedrock-specific errors
552    // if is_env_truthy(process.env.CLAUDE_CODE_USE_BEDROCK) && lower.contains("model id") {
553    //     return "bedrock_model_access";
554    // }
555
556    // Status code based fallbacks
557    if let Some(s) = status {
558        if s >= 500 {
559            return ApiErrorType::server_error;
560        }
561        if s >= 400 {
562            return ApiErrorType::client_error;
563        }
564    }
565
566    // Connection errors
567    if lower.contains("connection") || lower.contains("ssl") || lower.contains("tls") {
568        if lower.contains("ssl") || lower.contains("certificate") {
569            return ApiErrorType::ssl_cert_error;
570        }
571        return ApiErrorType::connection_error;
572    }
573
574    ApiErrorType::unknown
575}
576
577/// Categorize retryable API errors
578pub fn categorize_retryable_api_error(status: u16, message: &str) -> SDKAssistantMessageError {
579    if status == 529 || message.contains(r#""type":"overloaded_error""#) {
580        return SDKAssistantMessageError::rate_limit;
581    }
582    if status == 429 {
583        return SDKAssistantMessageError::rate_limit;
584    }
585    if status == 401 || status == 403 {
586        return SDKAssistantMessageError::authentication_failed;
587    }
588    if status >= 408 {
589        return SDKAssistantMessageError::server_error;
590    }
591    SDKAssistantMessageError::unknown
592}
593
594/// Get error message if refusal
595pub fn get_error_message_if_refusal(
596    stop_reason: Option<&str>,
597    model: &str,
598    is_non_interactive: bool,
599) -> Option<ApiErrorMessage> {
600    if stop_reason != Some("refusal") {
601        return None;
602    }
603
604    // In a real implementation, this would log an event
605    // logEvent('tengu_refusal_api_response', {});
606
607    let base_message = if is_non_interactive {
608        format!(
609            "{}: 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.",
610            API_ERROR_MESSAGE_PREFIX
611        )
612    } else {
613        format!(
614            "{}: 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.",
615            API_ERROR_MESSAGE_PREFIX
616        )
617    };
618
619    let model_suggestion = if model != "claude-sonnet-4-20250514" {
620        " If you are seeing this refusal repeatedly, try running /model claude-sonnet-4-20250514 to switch models."
621    } else {
622        ""
623    };
624
625    Some(create_assistant_api_error_message_with_options(
626        &(base_message + model_suggestion),
627        Some("invalid_request"),
628        None,
629    ))
630}
631
632/// Constant for no response requested
633pub const NO_RESPONSE_REQUESTED: &str = "NO_RESPONSE_REQUESTED";
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn test_starts_with_api_error_prefix() {
641        assert!(starts_with_api_error_prefix(
642            "API Error: something went wrong"
643        ));
644        assert!(starts_with_api_error_prefix(
645            "Please run /login · API Error: test"
646        ));
647        assert!(!starts_with_api_error_prefix("Something else"));
648    }
649
650    #[test]
651    fn test_is_terminal_task_status() {
652        assert!(!is_media_size_error("some random error"));
653        assert!(is_media_size_error("image exceeds 5 MB maximum"));
654        assert!(is_media_size_error(
655            "image dimensions exceed limit for many-image"
656        ));
657        assert!(is_media_size_error("maximum of 1000 PDF pages"));
658    }
659
660    #[test]
661    fn test_classify_api_error() {
662        assert_eq!(
663            classify_api_error("Request was aborted.", None),
664            ApiErrorType::aborted
665        );
666        assert_eq!(
667            classify_api_error("timeout error", None),
668            ApiErrorType::api_timeout
669        );
670        assert_eq!(
671            classify_api_error("rate limit", Some(429)),
672            ApiErrorType::rate_limit
673        );
674        assert_eq!(
675            classify_api_error("server overloaded", Some(529)),
676            ApiErrorType::server_overload
677        );
678        assert_eq!(
679            classify_api_error("Prompt is too long", None),
680            ApiErrorType::prompt_too_long
681        );
682    }
683
684    #[test]
685    fn test_categorize_retryable_api_error() {
686        assert_eq!(
687            categorize_retryable_api_error(529, "overloaded"),
688            SDKAssistantMessageError::rate_limit
689        );
690        assert_eq!(
691            categorize_retryable_api_error(429, "rate limit"),
692            SDKAssistantMessageError::rate_limit
693        );
694        assert_eq!(
695            categorize_retryable_api_error(401, "unauthorized"),
696            SDKAssistantMessageError::authentication_failed
697        );
698        assert_eq!(
699            categorize_retryable_api_error(500, "server error"),
700            SDKAssistantMessageError::server_error
701        );
702    }
703
704    #[test]
705    fn test_parse_prompt_too_long_token_counts() {
706        let (actual, limit) = parse_prompt_too_long_token_counts(
707            "prompt is too long: 137500 tokens > 135000 maximum",
708        );
709        assert_eq!(actual, Some(137500));
710        assert_eq!(limit, Some(135000));
711    }
712}