/ services / api / errors.ts
errors.ts
   1  import {
   2    APIConnectionError,
   3    APIConnectionTimeoutError,
   4    APIError,
   5  } from '@anthropic-ai/sdk'
   6  import type {
   7    BetaMessage,
   8    BetaStopReason,
   9  } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
  10  import { AFK_MODE_BETA_HEADER } from 'src/constants/betas.js'
  11  import type { SDKAssistantMessageError } from 'src/entrypoints/agentSdkTypes.js'
  12  import type {
  13    AssistantMessage,
  14    Message,
  15    UserMessage,
  16  } from 'src/types/message.js'
  17  import {
  18    getAnthropicApiKeyWithSource,
  19    getClaudeAIOAuthTokens,
  20    getOauthAccountInfo,
  21    isClaudeAISubscriber,
  22  } from 'src/utils/auth.js'
  23  import {
  24    createAssistantAPIErrorMessage,
  25    NO_RESPONSE_REQUESTED,
  26  } from 'src/utils/messages.js'
  27  import {
  28    getDefaultMainLoopModelSetting,
  29    isNonCustomOpusModel,
  30  } from 'src/utils/model/model.js'
  31  import { getModelStrings } from 'src/utils/model/modelStrings.js'
  32  import { getAPIProvider } from 'src/utils/model/providers.js'
  33  import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
  34  import {
  35    API_PDF_MAX_PAGES,
  36    PDF_TARGET_RAW_SIZE,
  37  } from '../../constants/apiLimits.js'
  38  import { isEnvTruthy } from '../../utils/envUtils.js'
  39  import { formatFileSize } from '../../utils/format.js'
  40  import { ImageResizeError } from '../../utils/imageResizer.js'
  41  import { ImageSizeError } from '../../utils/imageValidation.js'
  42  import {
  43    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  44    logEvent,
  45  } from '../analytics/index.js'
  46  import {
  47    type ClaudeAILimits,
  48    getRateLimitErrorMessage,
  49    type OverageDisabledReason,
  50  } from '../claudeAiLimits.js'
  51  import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command
  52  import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js'
  53  
  54  export const API_ERROR_MESSAGE_PREFIX = 'API Error'
  55  
  56  export function startsWithApiErrorPrefix(text: string): boolean {
  57    return (
  58      text.startsWith(API_ERROR_MESSAGE_PREFIX) ||
  59      text.startsWith(`Please run /login · ${API_ERROR_MESSAGE_PREFIX}`)
  60    )
  61  }
  62  export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'Prompt is too long'
  63  
  64  export function isPromptTooLongMessage(msg: AssistantMessage): boolean {
  65    if (!msg.isApiErrorMessage) {
  66      return false
  67    }
  68    const content = msg.message.content
  69    if (!Array.isArray(content)) {
  70      return false
  71    }
  72    return content.some(
  73      block =>
  74        block.type === 'text' &&
  75        block.text.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE),
  76    )
  77  }
  78  
  79  /**
  80   * Parse actual/limit token counts from a raw prompt-too-long API error
  81   * message like "prompt is too long: 137500 tokens > 135000 maximum".
  82   * The raw string may be wrapped in SDK prefixes or JSON envelopes, or
  83   * have different casing (Vertex), so this is intentionally lenient.
  84   */
  85  export function parsePromptTooLongTokenCounts(rawMessage: string): {
  86    actualTokens: number | undefined
  87    limitTokens: number | undefined
  88  } {
  89    const match = rawMessage.match(
  90      /prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i,
  91    )
  92    return {
  93      actualTokens: match ? parseInt(match[1]!, 10) : undefined,
  94      limitTokens: match ? parseInt(match[2]!, 10) : undefined,
  95    }
  96  }
  97  
  98  /**
  99   * Returns how many tokens over the limit a prompt-too-long error reports,
 100   * or undefined if the message isn't PTL or its errorDetails are unparseable.
 101   * Reactive compact uses this gap to jump past multiple groups in one retry
 102   * instead of peeling one-at-a-time.
 103   */
 104  export function getPromptTooLongTokenGap(
 105    msg: AssistantMessage,
 106  ): number | undefined {
 107    if (!isPromptTooLongMessage(msg) || !msg.errorDetails) {
 108      return undefined
 109    }
 110    const { actualTokens, limitTokens } = parsePromptTooLongTokenCounts(
 111      msg.errorDetails,
 112    )
 113    if (actualTokens === undefined || limitTokens === undefined) {
 114      return undefined
 115    }
 116    const gap = actualTokens - limitTokens
 117    return gap > 0 ? gap : undefined
 118  }
 119  
 120  /**
 121   * Is this raw API error text a media-size rejection that stripImagesFromMessages
 122   * can fix? Reactive compact's summarize retry uses this to decide whether to
 123   * strip and retry (media error) or bail (anything else).
 124   *
 125   * Patterns MUST stay in sync with the getAssistantMessageFromError branches
 126   * that populate errorDetails (~L523 PDF, ~L560 image, ~L573 many-image) and
 127   * the classifyAPIError branches (~L929-946). The closed loop: errorDetails is
 128   * only set after those branches already matched these same substrings, so
 129   * isMediaSizeError(errorDetails) is tautologically true for that path. API
 130   * wording drift causes graceful degradation (errorDetails stays undefined,
 131   * caller short-circuits), not a false negative.
 132   */
 133  export function isMediaSizeError(raw: string): boolean {
 134    return (
 135      (raw.includes('image exceeds') && raw.includes('maximum')) ||
 136      (raw.includes('image dimensions exceed') && raw.includes('many-image')) ||
 137      /maximum of \d+ PDF pages/.test(raw)
 138    )
 139  }
 140  
 141  /**
 142   * Message-level predicate: is this assistant message a media-size rejection?
 143   * Parallel to isPromptTooLongMessage. Checks errorDetails (the raw API error
 144   * string populated by the getAssistantMessageFromError branches at ~L523/560/573)
 145   * rather than content text, since media errors have per-variant content strings.
 146   */
 147  export function isMediaSizeErrorMessage(msg: AssistantMessage): boolean {
 148    return (
 149      msg.isApiErrorMessage === true &&
 150      msg.errorDetails !== undefined &&
 151      isMediaSizeError(msg.errorDetails)
 152    )
 153  }
 154  export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low'
 155  export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login'
 156  export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL =
 157    'Invalid API key · Fix external API key'
 158  export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH =
 159    'Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead'
 160  export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY =
 161    'Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable'
 162  export const TOKEN_REVOKED_ERROR_MESSAGE =
 163    'OAuth token revoked · Please run /login'
 164  export const CCR_AUTH_ERROR_MESSAGE =
 165    'Authentication error · This may be a temporary network issue, please try again'
 166  export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors'
 167  export const CUSTOM_OFF_SWITCH_MESSAGE =
 168    'Opus is experiencing high load, please use /model to switch to Sonnet'
 169  export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out'
 170  export function getPdfTooLargeErrorMessage(): string {
 171    const limits = `max ${API_PDF_MAX_PAGES} pages, ${formatFileSize(PDF_TARGET_RAW_SIZE)}`
 172    return getIsNonInteractiveSession()
 173      ? `PDF too large (${limits}). Try reading the file a different way (e.g., extract text with pdftotext).`
 174      : `PDF too large (${limits}). Double press esc to go back and try again, or use pdftotext to convert to text first.`
 175  }
 176  export function getPdfPasswordProtectedErrorMessage(): string {
 177    return getIsNonInteractiveSession()
 178      ? 'PDF is password protected. Try using a CLI tool to extract or convert the PDF.'
 179      : 'PDF is password protected. Please double press esc to edit your message and try again.'
 180  }
 181  export function getPdfInvalidErrorMessage(): string {
 182    return getIsNonInteractiveSession()
 183      ? 'The PDF file was not valid. Try converting it to text first (e.g., pdftotext).'
 184      : 'The PDF file was not valid. Double press esc to go back and try again with a different file.'
 185  }
 186  export function getImageTooLargeErrorMessage(): string {
 187    return getIsNonInteractiveSession()
 188      ? 'Image was too large. Try resizing the image or using a different approach.'
 189      : 'Image was too large. Double press esc to go back and try again with a smaller image.'
 190  }
 191  export function getRequestTooLargeErrorMessage(): string {
 192    const limits = `max ${formatFileSize(PDF_TARGET_RAW_SIZE)}`
 193    return getIsNonInteractiveSession()
 194      ? `Request too large (${limits}). Try with a smaller file.`
 195      : `Request too large (${limits}). Double press esc to go back and try with a smaller file.`
 196  }
 197  export const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE =
 198    'Your account does not have access to Claude Code. Please run /login.'
 199  
 200  export function getTokenRevokedErrorMessage(): string {
 201    return getIsNonInteractiveSession()
 202      ? 'Your account does not have access to Claude. Please login again or contact your administrator.'
 203      : TOKEN_REVOKED_ERROR_MESSAGE
 204  }
 205  
 206  export function getOauthOrgNotAllowedErrorMessage(): string {
 207    return getIsNonInteractiveSession()
 208      ? 'Your organization does not have access to Claude. Please login again or contact your administrator.'
 209      : OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE
 210  }
 211  
 212  /**
 213   * Check if we're in CCR (Claude Code Remote) mode.
 214   * In CCR mode, auth is handled via JWTs provided by the infrastructure,
 215   * not via /login. Transient auth errors should suggest retrying, not logging in.
 216   */
 217  function isCCRMode(): boolean {
 218    return isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)
 219  }
 220  
 221  // Temp helper to log tool_use/tool_result mismatch errors
 222  function logToolUseToolResultMismatch(
 223    toolUseId: string,
 224    messages: Message[],
 225    messagesForAPI: (UserMessage | AssistantMessage)[],
 226  ): void {
 227    try {
 228      // Find tool_use in normalized messages
 229      let normalizedIndex = -1
 230      for (let i = 0; i < messagesForAPI.length; i++) {
 231        const msg = messagesForAPI[i]
 232        if (!msg) continue
 233        const content = msg.message.content
 234        if (Array.isArray(content)) {
 235          for (const block of content) {
 236            if (
 237              block.type === 'tool_use' &&
 238              'id' in block &&
 239              block.id === toolUseId
 240            ) {
 241              normalizedIndex = i
 242              break
 243            }
 244          }
 245        }
 246        if (normalizedIndex !== -1) break
 247      }
 248  
 249      // Find tool_use in original messages
 250      let originalIndex = -1
 251      for (let i = 0; i < messages.length; i++) {
 252        const msg = messages[i]
 253        if (!msg) continue
 254        if (msg.type === 'assistant' && 'message' in msg) {
 255          const content = msg.message.content
 256          if (Array.isArray(content)) {
 257            for (const block of content) {
 258              if (
 259                block.type === 'tool_use' &&
 260                'id' in block &&
 261                block.id === toolUseId
 262              ) {
 263                originalIndex = i
 264                break
 265              }
 266            }
 267          }
 268        }
 269        if (originalIndex !== -1) break
 270      }
 271  
 272      // Build normalized sequence
 273      const normalizedSeq: string[] = []
 274      for (let i = normalizedIndex + 1; i < messagesForAPI.length; i++) {
 275        const msg = messagesForAPI[i]
 276        if (!msg) continue
 277        const content = msg.message.content
 278        if (Array.isArray(content)) {
 279          for (const block of content) {
 280            const role = msg.message.role
 281            if (block.type === 'tool_use' && 'id' in block) {
 282              normalizedSeq.push(`${role}:tool_use:${block.id}`)
 283            } else if (block.type === 'tool_result' && 'tool_use_id' in block) {
 284              normalizedSeq.push(`${role}:tool_result:${block.tool_use_id}`)
 285            } else if (block.type === 'text') {
 286              normalizedSeq.push(`${role}:text`)
 287            } else if (block.type === 'thinking') {
 288              normalizedSeq.push(`${role}:thinking`)
 289            } else if (block.type === 'image') {
 290              normalizedSeq.push(`${role}:image`)
 291            } else {
 292              normalizedSeq.push(`${role}:${block.type}`)
 293            }
 294          }
 295        } else if (typeof content === 'string') {
 296          normalizedSeq.push(`${msg.message.role}:string_content`)
 297        }
 298      }
 299  
 300      // Build pre-normalized sequence
 301      const preNormalizedSeq: string[] = []
 302      for (let i = originalIndex + 1; i < messages.length; i++) {
 303        const msg = messages[i]
 304        if (!msg) continue
 305  
 306        switch (msg.type) {
 307          case 'user':
 308          case 'assistant': {
 309            if ('message' in msg) {
 310              const content = msg.message.content
 311              if (Array.isArray(content)) {
 312                for (const block of content) {
 313                  const role = msg.message.role
 314                  if (block.type === 'tool_use' && 'id' in block) {
 315                    preNormalizedSeq.push(`${role}:tool_use:${block.id}`)
 316                  } else if (
 317                    block.type === 'tool_result' &&
 318                    'tool_use_id' in block
 319                  ) {
 320                    preNormalizedSeq.push(
 321                      `${role}:tool_result:${block.tool_use_id}`,
 322                    )
 323                  } else if (block.type === 'text') {
 324                    preNormalizedSeq.push(`${role}:text`)
 325                  } else if (block.type === 'thinking') {
 326                    preNormalizedSeq.push(`${role}:thinking`)
 327                  } else if (block.type === 'image') {
 328                    preNormalizedSeq.push(`${role}:image`)
 329                  } else {
 330                    preNormalizedSeq.push(`${role}:${block.type}`)
 331                  }
 332                }
 333              } else if (typeof content === 'string') {
 334                preNormalizedSeq.push(`${msg.message.role}:string_content`)
 335              }
 336            }
 337            break
 338          }
 339          case 'attachment':
 340            if ('attachment' in msg) {
 341              preNormalizedSeq.push(`attachment:${msg.attachment.type}`)
 342            }
 343            break
 344          case 'system':
 345            if ('subtype' in msg) {
 346              preNormalizedSeq.push(`system:${msg.subtype}`)
 347            }
 348            break
 349          case 'progress':
 350            if (
 351              'progress' in msg &&
 352              msg.progress &&
 353              typeof msg.progress === 'object' &&
 354              'type' in msg.progress
 355            ) {
 356              preNormalizedSeq.push(`progress:${msg.progress.type ?? 'unknown'}`)
 357            } else {
 358              preNormalizedSeq.push('progress:unknown')
 359            }
 360            break
 361        }
 362      }
 363  
 364      // Log to Statsig
 365      logEvent('tengu_tool_use_tool_result_mismatch_error', {
 366        toolUseId:
 367          toolUseId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 368        normalizedSequence: normalizedSeq.join(
 369          ', ',
 370        ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 371        preNormalizedSequence: preNormalizedSeq.join(
 372          ', ',
 373        ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 374        normalizedMessageCount: messagesForAPI.length,
 375        originalMessageCount: messages.length,
 376        normalizedToolUseIndex: normalizedIndex,
 377        originalToolUseIndex: originalIndex,
 378      })
 379    } catch (_) {
 380      // Ignore errors in debug logging
 381    }
 382  }
 383  
 384  /**
 385   * Type guard to check if a value is a valid Message response from the API
 386   */
 387  export function isValidAPIMessage(value: unknown): value is BetaMessage {
 388    return (
 389      typeof value === 'object' &&
 390      value !== null &&
 391      'content' in value &&
 392      'model' in value &&
 393      'usage' in value &&
 394      Array.isArray((value as BetaMessage).content) &&
 395      typeof (value as BetaMessage).model === 'string' &&
 396      typeof (value as BetaMessage).usage === 'object'
 397    )
 398  }
 399  
 400  /** Lower-level error that AWS can return. */
 401  type AmazonError = {
 402    Output?: {
 403      __type?: string
 404    }
 405    Version?: string
 406  }
 407  
 408  /**
 409   * Given a response that doesn't look quite right, see if it contains any known error types we can extract.
 410   */
 411  export function extractUnknownErrorFormat(value: unknown): string | undefined {
 412    // Check if value is a valid object first
 413    if (!value || typeof value !== 'object') {
 414      return undefined
 415    }
 416  
 417    // Amazon Bedrock routing errors
 418    if ((value as AmazonError).Output?.__type) {
 419      return (value as AmazonError).Output!.__type
 420    }
 421  
 422    return undefined
 423  }
 424  
 425  export function getAssistantMessageFromError(
 426    error: unknown,
 427    model: string,
 428    options?: {
 429      messages?: Message[]
 430      messagesForAPI?: (UserMessage | AssistantMessage)[]
 431    },
 432  ): AssistantMessage {
 433    // Check for SDK timeout errors
 434    if (
 435      error instanceof APIConnectionTimeoutError ||
 436      (error instanceof APIConnectionError &&
 437        error.message.toLowerCase().includes('timeout'))
 438    ) {
 439      return createAssistantAPIErrorMessage({
 440        content: API_TIMEOUT_ERROR_MESSAGE,
 441        error: 'unknown',
 442      })
 443    }
 444  
 445    // Check for image size/resize errors (thrown before API call during validation)
 446    // Use getImageTooLargeErrorMessage() to show "esc esc" hint for CLI users
 447    // but a generic message for SDK users (non-interactive mode)
 448    if (error instanceof ImageSizeError || error instanceof ImageResizeError) {
 449      return createAssistantAPIErrorMessage({
 450        content: getImageTooLargeErrorMessage(),
 451      })
 452    }
 453  
 454    // Check for emergency capacity off switch for Opus PAYG users
 455    if (
 456      error instanceof Error &&
 457      error.message.includes(CUSTOM_OFF_SWITCH_MESSAGE)
 458    ) {
 459      return createAssistantAPIErrorMessage({
 460        content: CUSTOM_OFF_SWITCH_MESSAGE,
 461        error: 'rate_limit',
 462      })
 463    }
 464  
 465    if (
 466      error instanceof APIError &&
 467      error.status === 429 &&
 468      shouldProcessRateLimits(isClaudeAISubscriber())
 469    ) {
 470      // Check if this is the new API with multiple rate limit headers
 471      const rateLimitType = error.headers?.get?.(
 472        'anthropic-ratelimit-unified-representative-claim',
 473      ) as 'five_hour' | 'seven_day' | 'seven_day_opus' | null
 474  
 475      const overageStatus = error.headers?.get?.(
 476        'anthropic-ratelimit-unified-overage-status',
 477      ) as 'allowed' | 'allowed_warning' | 'rejected' | null
 478  
 479      // If we have the new headers, use the new message generation
 480      if (rateLimitType || overageStatus) {
 481        // Build limits object from error headers to determine the appropriate message
 482        const limits: ClaudeAILimits = {
 483          status: 'rejected',
 484          unifiedRateLimitFallbackAvailable: false,
 485          isUsingOverage: false,
 486        }
 487  
 488        // Extract rate limit information from headers
 489        const resetHeader = error.headers?.get?.(
 490          'anthropic-ratelimit-unified-reset',
 491        )
 492        if (resetHeader) {
 493          limits.resetsAt = Number(resetHeader)
 494        }
 495  
 496        if (rateLimitType) {
 497          limits.rateLimitType = rateLimitType
 498        }
 499  
 500        if (overageStatus) {
 501          limits.overageStatus = overageStatus
 502        }
 503  
 504        const overageResetHeader = error.headers?.get?.(
 505          'anthropic-ratelimit-unified-overage-reset',
 506        )
 507        if (overageResetHeader) {
 508          limits.overageResetsAt = Number(overageResetHeader)
 509        }
 510  
 511        const overageDisabledReason = error.headers?.get?.(
 512          'anthropic-ratelimit-unified-overage-disabled-reason',
 513        ) as OverageDisabledReason | null
 514        if (overageDisabledReason) {
 515          limits.overageDisabledReason = overageDisabledReason
 516        }
 517  
 518        // Use the new message format for all new API rate limits
 519        const specificErrorMessage = getRateLimitErrorMessage(limits, model)
 520        if (specificErrorMessage) {
 521          return createAssistantAPIErrorMessage({
 522            content: specificErrorMessage,
 523            error: 'rate_limit',
 524          })
 525        }
 526  
 527        // If getRateLimitErrorMessage returned null, it means the fallback mechanism
 528        // will handle this silently (e.g., Opus -> Sonnet fallback for eligible users).
 529        // Return NO_RESPONSE_REQUESTED so no error is shown to the user, but the
 530        // message is still recorded in conversation history for Claude to see.
 531        return createAssistantAPIErrorMessage({
 532          content: NO_RESPONSE_REQUESTED,
 533          error: 'rate_limit',
 534        })
 535      }
 536  
 537      // No quota headers — this is NOT a quota limit. Surface what the API actually
 538      // said instead of a generic "Rate limit reached". Entitlement rejections
 539      // (e.g. 1M context without Extra Usage) and infra capacity 429s land here.
 540      if (error.message.includes('Extra usage is required for long context')) {
 541        const hint = getIsNonInteractiveSession()
 542          ? 'enable extra usage at claude.ai/settings/usage, or use --model to switch to standard context'
 543          : 'run /extra-usage to enable, or /model to switch to standard context'
 544        return createAssistantAPIErrorMessage({
 545          content: `${API_ERROR_MESSAGE_PREFIX}: Extra usage is required for 1M context · ${hint}`,
 546          error: 'rate_limit',
 547        })
 548      }
 549      // SDK's APIError.makeMessage prepends "429 " and JSON-stringifies the body
 550      // when there's no top-level .message — extract the inner error.message.
 551      const stripped = error.message.replace(/^429\s+/, '')
 552      const innerMessage = stripped.match(/"message"\s*:\s*"([^"]*)"/)?.[1]
 553      const detail = innerMessage || stripped
 554      return createAssistantAPIErrorMessage({
 555        content: `${API_ERROR_MESSAGE_PREFIX}: Request rejected (429) · ${detail || 'this may be a temporary capacity issue — check status.anthropic.com'}`,
 556        error: 'rate_limit',
 557      })
 558    }
 559  
 560    // Handle prompt too long errors (Vertex returns 413, direct API returns 400)
 561    // Use case-insensitive check since Vertex returns "Prompt is too long" (capitalized)
 562    if (
 563      error instanceof Error &&
 564      error.message.toLowerCase().includes('prompt is too long')
 565    ) {
 566      // Content stays generic (UI matches on exact string). The raw error with
 567      // token counts goes into errorDetails — reactive compact's retry loop
 568      // parses the gap from there via getPromptTooLongTokenGap.
 569      return createAssistantAPIErrorMessage({
 570        content: PROMPT_TOO_LONG_ERROR_MESSAGE,
 571        error: 'invalid_request',
 572        errorDetails: error.message,
 573      })
 574    }
 575  
 576    // Check for PDF page limit errors
 577    if (
 578      error instanceof Error &&
 579      /maximum of \d+ PDF pages/.test(error.message)
 580    ) {
 581      return createAssistantAPIErrorMessage({
 582        content: getPdfTooLargeErrorMessage(),
 583        error: 'invalid_request',
 584        errorDetails: error.message,
 585      })
 586    }
 587  
 588    // Check for password-protected PDF errors
 589    if (
 590      error instanceof Error &&
 591      error.message.includes('The PDF specified is password protected')
 592    ) {
 593      return createAssistantAPIErrorMessage({
 594        content: getPdfPasswordProtectedErrorMessage(),
 595        error: 'invalid_request',
 596      })
 597    }
 598  
 599    // Check for invalid PDF errors (e.g., HTML file renamed to .pdf)
 600    // Without this handler, invalid PDF document blocks persist in conversation
 601    // context and cause every subsequent API call to fail with 400.
 602    if (
 603      error instanceof Error &&
 604      error.message.includes('The PDF specified was not valid')
 605    ) {
 606      return createAssistantAPIErrorMessage({
 607        content: getPdfInvalidErrorMessage(),
 608        error: 'invalid_request',
 609      })
 610    }
 611  
 612    // Check for image size errors (e.g., "image exceeds 5 MB maximum: 5316852 bytes > 5242880 bytes")
 613    if (
 614      error instanceof APIError &&
 615      error.status === 400 &&
 616      error.message.includes('image exceeds') &&
 617      error.message.includes('maximum')
 618    ) {
 619      return createAssistantAPIErrorMessage({
 620        content: getImageTooLargeErrorMessage(),
 621        errorDetails: error.message,
 622      })
 623    }
 624  
 625    // Check for many-image dimension errors (API enforces stricter 2000px limit for many-image requests)
 626    if (
 627      error instanceof APIError &&
 628      error.status === 400 &&
 629      error.message.includes('image dimensions exceed') &&
 630      error.message.includes('many-image')
 631    ) {
 632      return createAssistantAPIErrorMessage({
 633        content: getIsNonInteractiveSession()
 634          ? 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.'
 635          : 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Run /compact to remove old images from context, or start a new session.',
 636        error: 'invalid_request',
 637        errorDetails: error.message,
 638      })
 639    }
 640  
 641    // Server rejected the afk-mode beta header (plan does not include auto
 642    // mode). AFK_MODE_BETA_HEADER is '' in non-TRANSCRIPT_CLASSIFIER builds,
 643    // so the truthy guard keeps this inert there.
 644    if (
 645      AFK_MODE_BETA_HEADER &&
 646      error instanceof APIError &&
 647      error.status === 400 &&
 648      error.message.includes(AFK_MODE_BETA_HEADER) &&
 649      error.message.includes('anthropic-beta')
 650    ) {
 651      return createAssistantAPIErrorMessage({
 652        content: 'Auto mode is unavailable for your plan',
 653        error: 'invalid_request',
 654      })
 655    }
 656  
 657    // Check for request too large errors (413 status)
 658    // This typically happens when a large PDF + conversation context exceeds the 32MB API limit
 659    if (error instanceof APIError && error.status === 413) {
 660      return createAssistantAPIErrorMessage({
 661        content: getRequestTooLargeErrorMessage(),
 662        error: 'invalid_request',
 663      })
 664    }
 665  
 666    // Check for tool_use/tool_result concurrency error
 667    if (
 668      error instanceof APIError &&
 669      error.status === 400 &&
 670      error.message.includes(
 671        '`tool_use` ids were found without `tool_result` blocks immediately after',
 672      )
 673    ) {
 674      // Log to Statsig if we have the message context
 675      if (options?.messages && options?.messagesForAPI) {
 676        const toolUseIdMatch = error.message.match(/toolu_[a-zA-Z0-9]+/)
 677        const toolUseId = toolUseIdMatch ? toolUseIdMatch[0] : null
 678        if (toolUseId) {
 679          logToolUseToolResultMismatch(
 680            toolUseId,
 681            options.messages,
 682            options.messagesForAPI,
 683          )
 684        }
 685      }
 686  
 687      if (process.env.USER_TYPE === 'ant') {
 688        const baseMessage = `API Error: 400 ${error.message}\n\nRun /share and post the JSON file to ${MACRO.FEEDBACK_CHANNEL}.`
 689        const rewindInstruction = getIsNonInteractiveSession()
 690          ? ''
 691          : ' Then, use /rewind to recover the conversation.'
 692        return createAssistantAPIErrorMessage({
 693          content: baseMessage + rewindInstruction,
 694          error: 'invalid_request',
 695        })
 696      } else {
 697        const baseMessage = 'API Error: 400 due to tool use concurrency issues.'
 698        const rewindInstruction = getIsNonInteractiveSession()
 699          ? ''
 700          : ' Run /rewind to recover the conversation.'
 701        return createAssistantAPIErrorMessage({
 702          content: baseMessage + rewindInstruction,
 703          error: 'invalid_request',
 704        })
 705      }
 706    }
 707  
 708    if (
 709      error instanceof APIError &&
 710      error.status === 400 &&
 711      error.message.includes('unexpected `tool_use_id` found in `tool_result`')
 712    ) {
 713      logEvent('tengu_unexpected_tool_result', {})
 714    }
 715  
 716    // Duplicate tool_use IDs (CC-1212). ensureToolResultPairing strips these
 717    // before send, so hitting this means a new corruption path slipped through.
 718    // Log for root-causing, and give users a recovery path instead of deadlock.
 719    if (
 720      error instanceof APIError &&
 721      error.status === 400 &&
 722      error.message.includes('`tool_use` ids must be unique')
 723    ) {
 724      logEvent('tengu_duplicate_tool_use_id', {})
 725      const rewindInstruction = getIsNonInteractiveSession()
 726        ? ''
 727        : ' Run /rewind to recover the conversation.'
 728      return createAssistantAPIErrorMessage({
 729        content: `API Error: 400 duplicate tool_use ID in conversation history.${rewindInstruction}`,
 730        error: 'invalid_request',
 731        errorDetails: error.message,
 732      })
 733    }
 734  
 735    // Check for invalid model name error for subscription users trying to use Opus
 736    if (
 737      isClaudeAISubscriber() &&
 738      error instanceof APIError &&
 739      error.status === 400 &&
 740      error.message.toLowerCase().includes('invalid model name') &&
 741      (isNonCustomOpusModel(model) || model === 'opus')
 742    ) {
 743      return createAssistantAPIErrorMessage({
 744        content:
 745          'Claude Opus is not available with the Claude Pro plan. If you have updated your subscription plan recently, run /logout and /login for the plan to take effect.',
 746        error: 'invalid_request',
 747      })
 748    }
 749  
 750    // Check for invalid model name error for Ant users. Claude Code may be
 751    // defaulting to a custom internal-only model for Ants, and there might be
 752    // Ants using new or unknown org IDs that haven't been gated in.
 753    if (
 754      process.env.USER_TYPE === 'ant' &&
 755      !process.env.ANTHROPIC_MODEL &&
 756      error instanceof Error &&
 757      error.message.toLowerCase().includes('invalid model name')
 758    ) {
 759      // Get organization ID from config - only use OAuth account data when actively using OAuth
 760      const orgId = getOauthAccountInfo()?.organizationUuid
 761      const baseMsg = `[ANT-ONLY] Your org isn't gated into the \`${model}\` model. Either run \`claude\` with \`ANTHROPIC_MODEL=${getDefaultMainLoopModelSetting()}\``
 762      const msg = orgId
 763        ? `${baseMsg} or share your orgId (${orgId}) in ${MACRO.FEEDBACK_CHANNEL} for help getting access.`
 764        : `${baseMsg} or reach out in ${MACRO.FEEDBACK_CHANNEL} for help getting access.`
 765  
 766      return createAssistantAPIErrorMessage({
 767        content: msg,
 768        error: 'invalid_request',
 769      })
 770    }
 771  
 772    if (
 773      error instanceof Error &&
 774      error.message.includes('Your credit balance is too low')
 775    ) {
 776      return createAssistantAPIErrorMessage({
 777        content: CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE,
 778        error: 'billing_error',
 779      })
 780    }
 781    // "Organization has been disabled" — commonly a stale ANTHROPIC_API_KEY
 782    // from a previous employer/project overriding subscription auth. Only handle
 783    // the env-var case; apiKeyHelper and /login-managed keys mean the active
 784    // auth's org is genuinely disabled with no dormant fallback to point at.
 785    if (
 786      error instanceof APIError &&
 787      error.status === 400 &&
 788      error.message.toLowerCase().includes('organization has been disabled')
 789    ) {
 790      const { source } = getAnthropicApiKeyWithSource()
 791      // getAnthropicApiKeyWithSource conflates the env var with FD-passed keys
 792      // under the same source value, and in CCR mode OAuth stays active despite
 793      // the env var. The three guards ensure we only blame the env var when it's
 794      // actually set and actually on the wire.
 795      if (
 796        source === 'ANTHROPIC_API_KEY' &&
 797        process.env.ANTHROPIC_API_KEY &&
 798        !isClaudeAISubscriber()
 799      ) {
 800        const hasStoredOAuth = getClaudeAIOAuthTokens()?.accessToken != null
 801        // Not 'authentication_failed' — that triggers VS Code's showLogin(), but
 802        // login can't fix this (approved env var keeps overriding OAuth). The fix
 803        // is configuration-based (unset the var), so invalid_request is correct.
 804        return createAssistantAPIErrorMessage({
 805          error: 'invalid_request',
 806          content: hasStoredOAuth
 807            ? ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH
 808            : ORG_DISABLED_ERROR_MESSAGE_ENV_KEY,
 809        })
 810      }
 811    }
 812  
 813    if (
 814      error instanceof Error &&
 815      error.message.toLowerCase().includes('x-api-key')
 816    ) {
 817      // In CCR mode, auth is via JWTs - this is likely a transient network issue
 818      if (isCCRMode()) {
 819        return createAssistantAPIErrorMessage({
 820          error: 'authentication_failed',
 821          content: CCR_AUTH_ERROR_MESSAGE,
 822        })
 823      }
 824  
 825      // Check if the API key is from an external source
 826      const { source } = getAnthropicApiKeyWithSource()
 827      const isExternalSource =
 828        source === 'ANTHROPIC_API_KEY' || source === 'apiKeyHelper'
 829  
 830      return createAssistantAPIErrorMessage({
 831        error: 'authentication_failed',
 832        content: isExternalSource
 833          ? INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL
 834          : INVALID_API_KEY_ERROR_MESSAGE,
 835      })
 836    }
 837  
 838    // Check for OAuth token revocation error
 839    if (
 840      error instanceof APIError &&
 841      error.status === 403 &&
 842      error.message.includes('OAuth token has been revoked')
 843    ) {
 844      return createAssistantAPIErrorMessage({
 845        error: 'authentication_failed',
 846        content: getTokenRevokedErrorMessage(),
 847      })
 848    }
 849  
 850    // Check for OAuth organization not allowed error
 851    if (
 852      error instanceof APIError &&
 853      (error.status === 401 || error.status === 403) &&
 854      error.message.includes(
 855        'OAuth authentication is currently not allowed for this organization',
 856      )
 857    ) {
 858      return createAssistantAPIErrorMessage({
 859        error: 'authentication_failed',
 860        content: getOauthOrgNotAllowedErrorMessage(),
 861      })
 862    }
 863  
 864    // Generic handler for other 401/403 authentication errors
 865    if (
 866      error instanceof APIError &&
 867      (error.status === 401 || error.status === 403)
 868    ) {
 869      // In CCR mode, auth is via JWTs - this is likely a transient network issue
 870      if (isCCRMode()) {
 871        return createAssistantAPIErrorMessage({
 872          error: 'authentication_failed',
 873          content: CCR_AUTH_ERROR_MESSAGE,
 874        })
 875      }
 876  
 877      return createAssistantAPIErrorMessage({
 878        error: 'authentication_failed',
 879        content: getIsNonInteractiveSession()
 880          ? `Failed to authenticate. ${API_ERROR_MESSAGE_PREFIX}: ${error.message}`
 881          : `Please run /login · ${API_ERROR_MESSAGE_PREFIX}: ${error.message}`,
 882      })
 883    }
 884  
 885    // Bedrock errors like "403 You don't have access to the model with the specified model ID."
 886    // don't contain the actual model ID
 887    if (
 888      isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) &&
 889      error instanceof Error &&
 890      error.message.toLowerCase().includes('model id')
 891    ) {
 892      const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model'
 893      const fallbackSuggestion = get3PModelFallbackSuggestion(model)
 894      return createAssistantAPIErrorMessage({
 895        content: fallbackSuggestion
 896          ? `${API_ERROR_MESSAGE_PREFIX} (${model}): ${error.message}. Try ${switchCmd} to switch to ${fallbackSuggestion}.`
 897          : `${API_ERROR_MESSAGE_PREFIX} (${model}): ${error.message}. Run ${switchCmd} to pick a different model.`,
 898        error: 'invalid_request',
 899      })
 900    }
 901  
 902    // 404 Not Found — usually means the selected model doesn't exist or isn't
 903    // available. Guide the user to /model so they can pick a valid one.
 904    // For 3P users, suggest a specific fallback model they can try.
 905    if (error instanceof APIError && error.status === 404) {
 906      const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model'
 907      const fallbackSuggestion = get3PModelFallbackSuggestion(model)
 908      return createAssistantAPIErrorMessage({
 909        content: fallbackSuggestion
 910          ? `The model ${model} is not available on your ${getAPIProvider()} deployment. Try ${switchCmd} to switch to ${fallbackSuggestion}, or ask your admin to enable this model.`
 911          : `There's an issue with the selected model (${model}). It may not exist or you may not have access to it. Run ${switchCmd} to pick a different model.`,
 912        error: 'invalid_request',
 913      })
 914    }
 915  
 916    // Connection errors (non-timeout) — use formatAPIError for detailed messages
 917    if (error instanceof APIConnectionError) {
 918      return createAssistantAPIErrorMessage({
 919        content: `${API_ERROR_MESSAGE_PREFIX}: ${formatAPIError(error)}`,
 920        error: 'unknown',
 921      })
 922    }
 923  
 924    if (error instanceof Error) {
 925      return createAssistantAPIErrorMessage({
 926        content: `${API_ERROR_MESSAGE_PREFIX}: ${error.message}`,
 927        error: 'unknown',
 928      })
 929    }
 930    return createAssistantAPIErrorMessage({
 931      content: API_ERROR_MESSAGE_PREFIX,
 932      error: 'unknown',
 933    })
 934  }
 935  
 936  /**
 937   * For 3P users, suggest a fallback model when the selected model is unavailable.
 938   * Returns a model name suggestion, or undefined if no suggestion is applicable.
 939   */
 940  function get3PModelFallbackSuggestion(model: string): string | undefined {
 941    if (getAPIProvider() === 'firstParty') {
 942      return undefined
 943    }
 944    // @[MODEL LAUNCH]: Add a fallback suggestion chain for the new model → previous version for 3P
 945    const m = model.toLowerCase()
 946    // If the failing model looks like an Opus 4.6 variant, suggest the default Opus (4.1 for 3P)
 947    if (m.includes('opus-4-6') || m.includes('opus_4_6')) {
 948      return getModelStrings().opus41
 949    }
 950    // If the failing model looks like a Sonnet 4.6 variant, suggest Sonnet 4.5
 951    if (m.includes('sonnet-4-6') || m.includes('sonnet_4_6')) {
 952      return getModelStrings().sonnet45
 953    }
 954    // If the failing model looks like a Sonnet 4.5 variant, suggest Sonnet 4
 955    if (m.includes('sonnet-4-5') || m.includes('sonnet_4_5')) {
 956      return getModelStrings().sonnet40
 957    }
 958    return undefined
 959  }
 960  
 961  /**
 962   * Classifies an API error into a specific error type for analytics tracking.
 963   * Returns a standardized error type string suitable for Datadog tagging.
 964   */
 965  export function classifyAPIError(error: unknown): string {
 966    // Aborted requests
 967    if (error instanceof Error && error.message === 'Request was aborted.') {
 968      return 'aborted'
 969    }
 970  
 971    // Timeout errors
 972    if (
 973      error instanceof APIConnectionTimeoutError ||
 974      (error instanceof APIConnectionError &&
 975        error.message.toLowerCase().includes('timeout'))
 976    ) {
 977      return 'api_timeout'
 978    }
 979  
 980    // Check for repeated 529 errors
 981    if (
 982      error instanceof Error &&
 983      error.message.includes(REPEATED_529_ERROR_MESSAGE)
 984    ) {
 985      return 'repeated_529'
 986    }
 987  
 988    // Check for emergency capacity off switch
 989    if (
 990      error instanceof Error &&
 991      error.message.includes(CUSTOM_OFF_SWITCH_MESSAGE)
 992    ) {
 993      return 'capacity_off_switch'
 994    }
 995  
 996    // Rate limiting
 997    if (error instanceof APIError && error.status === 429) {
 998      return 'rate_limit'
 999    }
1000  
1001    // Server overload (529)
1002    if (
1003      error instanceof APIError &&
1004      (error.status === 529 ||
1005        error.message?.includes('"type":"overloaded_error"'))
1006    ) {
1007      return 'server_overload'
1008    }
1009  
1010    // Prompt/content size errors
1011    if (
1012      error instanceof Error &&
1013      error.message
1014        .toLowerCase()
1015        .includes(PROMPT_TOO_LONG_ERROR_MESSAGE.toLowerCase())
1016    ) {
1017      return 'prompt_too_long'
1018    }
1019  
1020    // PDF errors
1021    if (
1022      error instanceof Error &&
1023      /maximum of \d+ PDF pages/.test(error.message)
1024    ) {
1025      return 'pdf_too_large'
1026    }
1027  
1028    if (
1029      error instanceof Error &&
1030      error.message.includes('The PDF specified is password protected')
1031    ) {
1032      return 'pdf_password_protected'
1033    }
1034  
1035    // Image size errors
1036    if (
1037      error instanceof APIError &&
1038      error.status === 400 &&
1039      error.message.includes('image exceeds') &&
1040      error.message.includes('maximum')
1041    ) {
1042      return 'image_too_large'
1043    }
1044  
1045    // Many-image dimension errors
1046    if (
1047      error instanceof APIError &&
1048      error.status === 400 &&
1049      error.message.includes('image dimensions exceed') &&
1050      error.message.includes('many-image')
1051    ) {
1052      return 'image_too_large'
1053    }
1054  
1055    // Tool use errors (400)
1056    if (
1057      error instanceof APIError &&
1058      error.status === 400 &&
1059      error.message.includes(
1060        '`tool_use` ids were found without `tool_result` blocks immediately after',
1061      )
1062    ) {
1063      return 'tool_use_mismatch'
1064    }
1065  
1066    if (
1067      error instanceof APIError &&
1068      error.status === 400 &&
1069      error.message.includes('unexpected `tool_use_id` found in `tool_result`')
1070    ) {
1071      return 'unexpected_tool_result'
1072    }
1073  
1074    if (
1075      error instanceof APIError &&
1076      error.status === 400 &&
1077      error.message.includes('`tool_use` ids must be unique')
1078    ) {
1079      return 'duplicate_tool_use_id'
1080    }
1081  
1082    // Invalid model errors (400)
1083    if (
1084      error instanceof APIError &&
1085      error.status === 400 &&
1086      error.message.toLowerCase().includes('invalid model name')
1087    ) {
1088      return 'invalid_model'
1089    }
1090  
1091    // Credit/billing errors
1092    if (
1093      error instanceof Error &&
1094      error.message
1095        .toLowerCase()
1096        .includes(CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE.toLowerCase())
1097    ) {
1098      return 'credit_balance_low'
1099    }
1100  
1101    // Authentication errors
1102    if (
1103      error instanceof Error &&
1104      error.message.toLowerCase().includes('x-api-key')
1105    ) {
1106      return 'invalid_api_key'
1107    }
1108  
1109    if (
1110      error instanceof APIError &&
1111      error.status === 403 &&
1112      error.message.includes('OAuth token has been revoked')
1113    ) {
1114      return 'token_revoked'
1115    }
1116  
1117    if (
1118      error instanceof APIError &&
1119      (error.status === 401 || error.status === 403) &&
1120      error.message.includes(
1121        'OAuth authentication is currently not allowed for this organization',
1122      )
1123    ) {
1124      return 'oauth_org_not_allowed'
1125    }
1126  
1127    // Generic auth errors
1128    if (
1129      error instanceof APIError &&
1130      (error.status === 401 || error.status === 403)
1131    ) {
1132      return 'auth_error'
1133    }
1134  
1135    // Bedrock-specific errors
1136    if (
1137      isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) &&
1138      error instanceof Error &&
1139      error.message.toLowerCase().includes('model id')
1140    ) {
1141      return 'bedrock_model_access'
1142    }
1143  
1144    // Status code based fallbacks
1145    if (error instanceof APIError) {
1146      const status = error.status
1147      if (status >= 500) return 'server_error'
1148      if (status >= 400) return 'client_error'
1149    }
1150  
1151    // Connection errors - check for SSL/TLS issues first
1152    if (error instanceof APIConnectionError) {
1153      const connectionDetails = extractConnectionErrorDetails(error)
1154      if (connectionDetails?.isSSLError) {
1155        return 'ssl_cert_error'
1156      }
1157      return 'connection_error'
1158    }
1159  
1160    return 'unknown'
1161  }
1162  
1163  export function categorizeRetryableAPIError(
1164    error: APIError,
1165  ): SDKAssistantMessageError {
1166    if (
1167      error.status === 529 ||
1168      error.message?.includes('"type":"overloaded_error"')
1169    ) {
1170      return 'rate_limit'
1171    }
1172    if (error.status === 429) {
1173      return 'rate_limit'
1174    }
1175    if (error.status === 401 || error.status === 403) {
1176      return 'authentication_failed'
1177    }
1178    if (error.status !== undefined && error.status >= 408) {
1179      return 'server_error'
1180    }
1181    return 'unknown'
1182  }
1183  
1184  export function getErrorMessageIfRefusal(
1185    stopReason: BetaStopReason | null,
1186    model: string,
1187  ): AssistantMessage | undefined {
1188    if (stopReason !== 'refusal') {
1189      return
1190    }
1191  
1192    logEvent('tengu_refusal_api_response', {})
1193  
1194    const baseMessage = getIsNonInteractiveSession()
1195      ? `${API_ERROR_MESSAGE_PREFIX}: 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.`
1196      : `${API_ERROR_MESSAGE_PREFIX}: 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.`
1197  
1198    const modelSuggestion =
1199      model !== 'claude-sonnet-4-20250514'
1200        ? ' If you are seeing this refusal repeatedly, try running /model claude-sonnet-4-20250514 to switch models.'
1201        : ''
1202  
1203    return createAssistantAPIErrorMessage({
1204      content: baseMessage + modelSuggestion,
1205      error: 'invalid_request',
1206    })
1207  }