import { APIConnectionError, APIConnectionTimeoutError, APIError, } from '@anthropic-ai/sdk' import type { BetaMessage, BetaStopReason, } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import { AFK_MODE_BETA_HEADER } from 'src/constants/betas.js' import type { SDKAssistantMessageError } from 'src/entrypoints/agentSdkTypes.js' import type { AssistantMessage, Message, UserMessage, } from 'src/types/message.js' import { getAnthropicApiKeyWithSource, getClaudeAIOAuthTokens, getOauthAccountInfo, isClaudeAISubscriber, } from 'src/utils/auth.js' import { createAssistantAPIErrorMessage, NO_RESPONSE_REQUESTED, } from 'src/utils/messages.js' import { getDefaultMainLoopModelSetting, isNonCustomOpusModel, } from 'src/utils/model/model.js' import { getModelStrings } from 'src/utils/model/modelStrings.js' import { getAPIProvider } from 'src/utils/model/providers.js' import { getIsNonInteractiveSession } from '../../bootstrap/state.js' import { API_PDF_MAX_PAGES, PDF_TARGET_RAW_SIZE, } from '../../constants/apiLimits.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { formatFileSize } from '../../utils/format.js' import { ImageResizeError } from '../../utils/imageResizer.js' import { ImageSizeError } from '../../utils/imageValidation.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../analytics/index.js' import { type ClaudeAILimits, getRateLimitErrorMessage, type OverageDisabledReason, } from '../claudeAiLimits.js' import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js' export const API_ERROR_MESSAGE_PREFIX = 'API Error' export function startsWithApiErrorPrefix(text: string): boolean { return ( text.startsWith(API_ERROR_MESSAGE_PREFIX) || text.startsWith(`Please run /login · ${API_ERROR_MESSAGE_PREFIX}`) ) } export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'Prompt is too long' export function isPromptTooLongMessage(msg: AssistantMessage): boolean { if (!msg.isApiErrorMessage) { return false } const content = msg.message.content if (!Array.isArray(content)) { return false } return content.some( block => block.type === 'text' && block.text.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE), ) } /** * Parse actual/limit token counts from a raw prompt-too-long API error * message like "prompt is too long: 137500 tokens > 135000 maximum". * The raw string may be wrapped in SDK prefixes or JSON envelopes, or * have different casing (Vertex), so this is intentionally lenient. */ export function parsePromptTooLongTokenCounts(rawMessage: string): { actualTokens: number | undefined limitTokens: number | undefined } { const match = rawMessage.match( /prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i, ) return { actualTokens: match ? parseInt(match[1]!, 10) : undefined, limitTokens: match ? parseInt(match[2]!, 10) : undefined, } } /** * Returns how many tokens over the limit a prompt-too-long error reports, * or undefined if the message isn't PTL or its errorDetails are unparseable. * Reactive compact uses this gap to jump past multiple groups in one retry * instead of peeling one-at-a-time. */ export function getPromptTooLongTokenGap( msg: AssistantMessage, ): number | undefined { if (!isPromptTooLongMessage(msg) || !msg.errorDetails) { return undefined } const { actualTokens, limitTokens } = parsePromptTooLongTokenCounts( msg.errorDetails, ) if (actualTokens === undefined || limitTokens === undefined) { return undefined } const gap = actualTokens - limitTokens return gap > 0 ? gap : undefined } /** * Is this raw API error text a media-size rejection that stripImagesFromMessages * can fix? Reactive compact's summarize retry uses this to decide whether to * strip and retry (media error) or bail (anything else). * * Patterns MUST stay in sync with the getAssistantMessageFromError branches * that populate errorDetails (~L523 PDF, ~L560 image, ~L573 many-image) and * the classifyAPIError branches (~L929-946). The closed loop: errorDetails is * only set after those branches already matched these same substrings, so * isMediaSizeError(errorDetails) is tautologically true for that path. API * wording drift causes graceful degradation (errorDetails stays undefined, * caller short-circuits), not a false negative. */ export function isMediaSizeError(raw: string): boolean { return ( (raw.includes('image exceeds') && raw.includes('maximum')) || (raw.includes('image dimensions exceed') && raw.includes('many-image')) || /maximum of \d+ PDF pages/.test(raw) ) } /** * Message-level predicate: is this assistant message a media-size rejection? * Parallel to isPromptTooLongMessage. Checks errorDetails (the raw API error * string populated by the getAssistantMessageFromError branches at ~L523/560/573) * rather than content text, since media errors have per-variant content strings. */ export function isMediaSizeErrorMessage(msg: AssistantMessage): boolean { return ( msg.isApiErrorMessage === true && msg.errorDetails !== undefined && isMediaSizeError(msg.errorDetails) ) } export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low' export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login' export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL = 'Invalid API key · Fix external API key' export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH = 'Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead' export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY = 'Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable' export const TOKEN_REVOKED_ERROR_MESSAGE = 'OAuth token revoked · Please run /login' export const CCR_AUTH_ERROR_MESSAGE = 'Authentication error · This may be a temporary network issue, please try again' export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors' export const CUSTOM_OFF_SWITCH_MESSAGE = 'Opus is experiencing high load, please use /model to switch to Sonnet' export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out' export function getPdfTooLargeErrorMessage(): string { const limits = `max ${API_PDF_MAX_PAGES} pages, ${formatFileSize(PDF_TARGET_RAW_SIZE)}` return getIsNonInteractiveSession() ? `PDF too large (${limits}). Try reading the file a different way (e.g., extract text with pdftotext).` : `PDF too large (${limits}). Double press esc to go back and try again, or use pdftotext to convert to text first.` } export function getPdfPasswordProtectedErrorMessage(): string { return getIsNonInteractiveSession() ? 'PDF is password protected. Try using a CLI tool to extract or convert the PDF.' : 'PDF is password protected. Please double press esc to edit your message and try again.' } export function getPdfInvalidErrorMessage(): string { return getIsNonInteractiveSession() ? 'The PDF file was not valid. Try converting it to text first (e.g., pdftotext).' : 'The PDF file was not valid. Double press esc to go back and try again with a different file.' } export function getImageTooLargeErrorMessage(): string { return getIsNonInteractiveSession() ? 'Image was too large. Try resizing the image or using a different approach.' : 'Image was too large. Double press esc to go back and try again with a smaller image.' } export function getRequestTooLargeErrorMessage(): string { const limits = `max ${formatFileSize(PDF_TARGET_RAW_SIZE)}` return getIsNonInteractiveSession() ? `Request too large (${limits}). Try with a smaller file.` : `Request too large (${limits}). Double press esc to go back and try with a smaller file.` } export const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE = 'Your account does not have access to Claude Code. Please run /login.' export function getTokenRevokedErrorMessage(): string { return getIsNonInteractiveSession() ? 'Your account does not have access to Claude. Please login again or contact your administrator.' : TOKEN_REVOKED_ERROR_MESSAGE } export function getOauthOrgNotAllowedErrorMessage(): string { return getIsNonInteractiveSession() ? 'Your organization does not have access to Claude. Please login again or contact your administrator.' : OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE } /** * Check if we're in CCR (Claude Code Remote) mode. * In CCR mode, auth is handled via JWTs provided by the infrastructure, * not via /login. Transient auth errors should suggest retrying, not logging in. */ function isCCRMode(): boolean { return isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) } // Temp helper to log tool_use/tool_result mismatch errors function logToolUseToolResultMismatch( toolUseId: string, messages: Message[], messagesForAPI: (UserMessage | AssistantMessage)[], ): void { try { // Find tool_use in normalized messages let normalizedIndex = -1 for (let i = 0; i < messagesForAPI.length; i++) { const msg = messagesForAPI[i] if (!msg) continue const content = msg.message.content if (Array.isArray(content)) { for (const block of content) { if ( block.type === 'tool_use' && 'id' in block && block.id === toolUseId ) { normalizedIndex = i break } } } if (normalizedIndex !== -1) break } // Find tool_use in original messages let originalIndex = -1 for (let i = 0; i < messages.length; i++) { const msg = messages[i] if (!msg) continue if (msg.type === 'assistant' && 'message' in msg) { const content = msg.message.content if (Array.isArray(content)) { for (const block of content) { if ( block.type === 'tool_use' && 'id' in block && block.id === toolUseId ) { originalIndex = i break } } } } if (originalIndex !== -1) break } // Build normalized sequence const normalizedSeq: string[] = [] for (let i = normalizedIndex + 1; i < messagesForAPI.length; i++) { const msg = messagesForAPI[i] if (!msg) continue const content = msg.message.content if (Array.isArray(content)) { for (const block of content) { const role = msg.message.role if (block.type === 'tool_use' && 'id' in block) { normalizedSeq.push(`${role}:tool_use:${block.id}`) } else if (block.type === 'tool_result' && 'tool_use_id' in block) { normalizedSeq.push(`${role}:tool_result:${block.tool_use_id}`) } else if (block.type === 'text') { normalizedSeq.push(`${role}:text`) } else if (block.type === 'thinking') { normalizedSeq.push(`${role}:thinking`) } else if (block.type === 'image') { normalizedSeq.push(`${role}:image`) } else { normalizedSeq.push(`${role}:${block.type}`) } } } else if (typeof content === 'string') { normalizedSeq.push(`${msg.message.role}:string_content`) } } // Build pre-normalized sequence const preNormalizedSeq: string[] = [] for (let i = originalIndex + 1; i < messages.length; i++) { const msg = messages[i] if (!msg) continue switch (msg.type) { case 'user': case 'assistant': { if ('message' in msg) { const content = msg.message.content if (Array.isArray(content)) { for (const block of content) { const role = msg.message.role if (block.type === 'tool_use' && 'id' in block) { preNormalizedSeq.push(`${role}:tool_use:${block.id}`) } else if ( block.type === 'tool_result' && 'tool_use_id' in block ) { preNormalizedSeq.push( `${role}:tool_result:${block.tool_use_id}`, ) } else if (block.type === 'text') { preNormalizedSeq.push(`${role}:text`) } else if (block.type === 'thinking') { preNormalizedSeq.push(`${role}:thinking`) } else if (block.type === 'image') { preNormalizedSeq.push(`${role}:image`) } else { preNormalizedSeq.push(`${role}:${block.type}`) } } } else if (typeof content === 'string') { preNormalizedSeq.push(`${msg.message.role}:string_content`) } } break } case 'attachment': if ('attachment' in msg) { preNormalizedSeq.push(`attachment:${msg.attachment.type}`) } break case 'system': if ('subtype' in msg) { preNormalizedSeq.push(`system:${msg.subtype}`) } break case 'progress': if ( 'progress' in msg && msg.progress && typeof msg.progress === 'object' && 'type' in msg.progress ) { preNormalizedSeq.push(`progress:${msg.progress.type ?? 'unknown'}`) } else { preNormalizedSeq.push('progress:unknown') } break } } // Log to Statsig logEvent('tengu_tool_use_tool_result_mismatch_error', { toolUseId: toolUseId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, normalizedSequence: normalizedSeq.join( ', ', ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, preNormalizedSequence: preNormalizedSeq.join( ', ', ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, normalizedMessageCount: messagesForAPI.length, originalMessageCount: messages.length, normalizedToolUseIndex: normalizedIndex, originalToolUseIndex: originalIndex, }) } catch (_) { // Ignore errors in debug logging } } /** * Type guard to check if a value is a valid Message response from the API */ export function isValidAPIMessage(value: unknown): value is BetaMessage { return ( typeof value === 'object' && value !== null && 'content' in value && 'model' in value && 'usage' in value && Array.isArray((value as BetaMessage).content) && typeof (value as BetaMessage).model === 'string' && typeof (value as BetaMessage).usage === 'object' ) } /** Lower-level error that AWS can return. */ type AmazonError = { Output?: { __type?: string } Version?: string } /** * Given a response that doesn't look quite right, see if it contains any known error types we can extract. */ export function extractUnknownErrorFormat(value: unknown): string | undefined { // Check if value is a valid object first if (!value || typeof value !== 'object') { return undefined } // Amazon Bedrock routing errors if ((value as AmazonError).Output?.__type) { return (value as AmazonError).Output!.__type } return undefined } export function getAssistantMessageFromError( error: unknown, model: string, options?: { messages?: Message[] messagesForAPI?: (UserMessage | AssistantMessage)[] }, ): AssistantMessage { // Check for SDK timeout errors if ( error instanceof APIConnectionTimeoutError || (error instanceof APIConnectionError && error.message.toLowerCase().includes('timeout')) ) { return createAssistantAPIErrorMessage({ content: API_TIMEOUT_ERROR_MESSAGE, error: 'unknown', }) } // Check for image size/resize errors (thrown before API call during validation) // Use getImageTooLargeErrorMessage() to show "esc esc" hint for CLI users // but a generic message for SDK users (non-interactive mode) if (error instanceof ImageSizeError || error instanceof ImageResizeError) { return createAssistantAPIErrorMessage({ content: getImageTooLargeErrorMessage(), }) } // Check for emergency capacity off switch for Opus PAYG users if ( error instanceof Error && error.message.includes(CUSTOM_OFF_SWITCH_MESSAGE) ) { return createAssistantAPIErrorMessage({ content: CUSTOM_OFF_SWITCH_MESSAGE, error: 'rate_limit', }) } if ( error instanceof APIError && error.status === 429 && shouldProcessRateLimits(isClaudeAISubscriber()) ) { // Check if this is the new API with multiple rate limit headers const rateLimitType = error.headers?.get?.( 'anthropic-ratelimit-unified-representative-claim', ) as 'five_hour' | 'seven_day' | 'seven_day_opus' | null const overageStatus = error.headers?.get?.( 'anthropic-ratelimit-unified-overage-status', ) as 'allowed' | 'allowed_warning' | 'rejected' | null // If we have the new headers, use the new message generation if (rateLimitType || overageStatus) { // Build limits object from error headers to determine the appropriate message const limits: ClaudeAILimits = { status: 'rejected', unifiedRateLimitFallbackAvailable: false, isUsingOverage: false, } // Extract rate limit information from headers const resetHeader = error.headers?.get?.( 'anthropic-ratelimit-unified-reset', ) if (resetHeader) { limits.resetsAt = Number(resetHeader) } if (rateLimitType) { limits.rateLimitType = rateLimitType } if (overageStatus) { limits.overageStatus = overageStatus } const overageResetHeader = error.headers?.get?.( 'anthropic-ratelimit-unified-overage-reset', ) if (overageResetHeader) { limits.overageResetsAt = Number(overageResetHeader) } const overageDisabledReason = error.headers?.get?.( 'anthropic-ratelimit-unified-overage-disabled-reason', ) as OverageDisabledReason | null if (overageDisabledReason) { limits.overageDisabledReason = overageDisabledReason } // Use the new message format for all new API rate limits const specificErrorMessage = getRateLimitErrorMessage(limits, model) if (specificErrorMessage) { return createAssistantAPIErrorMessage({ content: specificErrorMessage, error: 'rate_limit', }) } // If getRateLimitErrorMessage returned null, it means the fallback mechanism // will handle this silently (e.g., Opus -> Sonnet fallback for eligible users). // Return NO_RESPONSE_REQUESTED so no error is shown to the user, but the // message is still recorded in conversation history for Claude to see. return createAssistantAPIErrorMessage({ content: NO_RESPONSE_REQUESTED, error: 'rate_limit', }) } // No quota headers — this is NOT a quota limit. Surface what the API actually // said instead of a generic "Rate limit reached". Entitlement rejections // (e.g. 1M context without Extra Usage) and infra capacity 429s land here. if (error.message.includes('Extra usage is required for long context')) { const hint = getIsNonInteractiveSession() ? 'enable extra usage at claude.ai/settings/usage, or use --model to switch to standard context' : 'run /extra-usage to enable, or /model to switch to standard context' return createAssistantAPIErrorMessage({ content: `${API_ERROR_MESSAGE_PREFIX}: Extra usage is required for 1M context · ${hint}`, error: 'rate_limit', }) } // SDK's APIError.makeMessage prepends "429 " and JSON-stringifies the body // when there's no top-level .message — extract the inner error.message. const stripped = error.message.replace(/^429\s+/, '') const innerMessage = stripped.match(/"message"\s*:\s*"([^"]*)"/)?.[1] const detail = innerMessage || stripped return createAssistantAPIErrorMessage({ content: `${API_ERROR_MESSAGE_PREFIX}: Request rejected (429) · ${detail || 'this may be a temporary capacity issue — check status.anthropic.com'}`, error: 'rate_limit', }) } // Handle prompt too long errors (Vertex returns 413, direct API returns 400) // Use case-insensitive check since Vertex returns "Prompt is too long" (capitalized) if ( error instanceof Error && error.message.toLowerCase().includes('prompt is too long') ) { // Content stays generic (UI matches on exact string). The raw error with // token counts goes into errorDetails — reactive compact's retry loop // parses the gap from there via getPromptTooLongTokenGap. return createAssistantAPIErrorMessage({ content: PROMPT_TOO_LONG_ERROR_MESSAGE, error: 'invalid_request', errorDetails: error.message, }) } // Check for PDF page limit errors if ( error instanceof Error && /maximum of \d+ PDF pages/.test(error.message) ) { return createAssistantAPIErrorMessage({ content: getPdfTooLargeErrorMessage(), error: 'invalid_request', errorDetails: error.message, }) } // Check for password-protected PDF errors if ( error instanceof Error && error.message.includes('The PDF specified is password protected') ) { return createAssistantAPIErrorMessage({ content: getPdfPasswordProtectedErrorMessage(), error: 'invalid_request', }) } // Check for invalid PDF errors (e.g., HTML file renamed to .pdf) // Without this handler, invalid PDF document blocks persist in conversation // context and cause every subsequent API call to fail with 400. if ( error instanceof Error && error.message.includes('The PDF specified was not valid') ) { return createAssistantAPIErrorMessage({ content: getPdfInvalidErrorMessage(), error: 'invalid_request', }) } // Check for image size errors (e.g., "image exceeds 5 MB maximum: 5316852 bytes > 5242880 bytes") if ( error instanceof APIError && error.status === 400 && error.message.includes('image exceeds') && error.message.includes('maximum') ) { return createAssistantAPIErrorMessage({ content: getImageTooLargeErrorMessage(), errorDetails: error.message, }) } // Check for many-image dimension errors (API enforces stricter 2000px limit for many-image requests) if ( error instanceof APIError && error.status === 400 && error.message.includes('image dimensions exceed') && error.message.includes('many-image') ) { return createAssistantAPIErrorMessage({ content: getIsNonInteractiveSession() ? 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.' : '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.', error: 'invalid_request', errorDetails: error.message, }) } // Server rejected the afk-mode beta header (plan does not include auto // mode). AFK_MODE_BETA_HEADER is '' in non-TRANSCRIPT_CLASSIFIER builds, // so the truthy guard keeps this inert there. if ( AFK_MODE_BETA_HEADER && error instanceof APIError && error.status === 400 && error.message.includes(AFK_MODE_BETA_HEADER) && error.message.includes('anthropic-beta') ) { return createAssistantAPIErrorMessage({ content: 'Auto mode is unavailable for your plan', error: 'invalid_request', }) } // Check for request too large errors (413 status) // This typically happens when a large PDF + conversation context exceeds the 32MB API limit if (error instanceof APIError && error.status === 413) { return createAssistantAPIErrorMessage({ content: getRequestTooLargeErrorMessage(), error: 'invalid_request', }) } // Check for tool_use/tool_result concurrency error if ( error instanceof APIError && error.status === 400 && error.message.includes( '`tool_use` ids were found without `tool_result` blocks immediately after', ) ) { // Log to Statsig if we have the message context if (options?.messages && options?.messagesForAPI) { const toolUseIdMatch = error.message.match(/toolu_[a-zA-Z0-9]+/) const toolUseId = toolUseIdMatch ? toolUseIdMatch[0] : null if (toolUseId) { logToolUseToolResultMismatch( toolUseId, options.messages, options.messagesForAPI, ) } } if (process.env.USER_TYPE === 'ant') { const baseMessage = `API Error: 400 ${error.message}\n\nRun /share and post the JSON file to ${MACRO.FEEDBACK_CHANNEL}.` const rewindInstruction = getIsNonInteractiveSession() ? '' : ' Then, use /rewind to recover the conversation.' return createAssistantAPIErrorMessage({ content: baseMessage + rewindInstruction, error: 'invalid_request', }) } else { const baseMessage = 'API Error: 400 due to tool use concurrency issues.' const rewindInstruction = getIsNonInteractiveSession() ? '' : ' Run /rewind to recover the conversation.' return createAssistantAPIErrorMessage({ content: baseMessage + rewindInstruction, error: 'invalid_request', }) } } if ( error instanceof APIError && error.status === 400 && error.message.includes('unexpected `tool_use_id` found in `tool_result`') ) { logEvent('tengu_unexpected_tool_result', {}) } // Duplicate tool_use IDs (CC-1212). ensureToolResultPairing strips these // before send, so hitting this means a new corruption path slipped through. // Log for root-causing, and give users a recovery path instead of deadlock. if ( error instanceof APIError && error.status === 400 && error.message.includes('`tool_use` ids must be unique') ) { logEvent('tengu_duplicate_tool_use_id', {}) const rewindInstruction = getIsNonInteractiveSession() ? '' : ' Run /rewind to recover the conversation.' return createAssistantAPIErrorMessage({ content: `API Error: 400 duplicate tool_use ID in conversation history.${rewindInstruction}`, error: 'invalid_request', errorDetails: error.message, }) } // Check for invalid model name error for subscription users trying to use Opus if ( isClaudeAISubscriber() && error instanceof APIError && error.status === 400 && error.message.toLowerCase().includes('invalid model name') && (isNonCustomOpusModel(model) || model === 'opus') ) { return createAssistantAPIErrorMessage({ content: '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.', error: 'invalid_request', }) } // Check for invalid model name error for Ant users. Claude Code may be // defaulting to a custom internal-only model for Ants, and there might be // Ants using new or unknown org IDs that haven't been gated in. if ( process.env.USER_TYPE === 'ant' && !process.env.ANTHROPIC_MODEL && error instanceof Error && error.message.toLowerCase().includes('invalid model name') ) { // Get organization ID from config - only use OAuth account data when actively using OAuth const orgId = getOauthAccountInfo()?.organizationUuid const baseMsg = `[ANT-ONLY] Your org isn't gated into the \`${model}\` model. Either run \`claude\` with \`ANTHROPIC_MODEL=${getDefaultMainLoopModelSetting()}\`` const msg = orgId ? `${baseMsg} or share your orgId (${orgId}) in ${MACRO.FEEDBACK_CHANNEL} for help getting access.` : `${baseMsg} or reach out in ${MACRO.FEEDBACK_CHANNEL} for help getting access.` return createAssistantAPIErrorMessage({ content: msg, error: 'invalid_request', }) } if ( error instanceof Error && error.message.includes('Your credit balance is too low') ) { return createAssistantAPIErrorMessage({ content: CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, error: 'billing_error', }) } // "Organization has been disabled" — commonly a stale ANTHROPIC_API_KEY // from a previous employer/project overriding subscription auth. Only handle // the env-var case; apiKeyHelper and /login-managed keys mean the active // auth's org is genuinely disabled with no dormant fallback to point at. if ( error instanceof APIError && error.status === 400 && error.message.toLowerCase().includes('organization has been disabled') ) { const { source } = getAnthropicApiKeyWithSource() // getAnthropicApiKeyWithSource conflates the env var with FD-passed keys // under the same source value, and in CCR mode OAuth stays active despite // the env var. The three guards ensure we only blame the env var when it's // actually set and actually on the wire. if ( source === 'ANTHROPIC_API_KEY' && process.env.ANTHROPIC_API_KEY && !isClaudeAISubscriber() ) { const hasStoredOAuth = getClaudeAIOAuthTokens()?.accessToken != null // Not 'authentication_failed' — that triggers VS Code's showLogin(), but // login can't fix this (approved env var keeps overriding OAuth). The fix // is configuration-based (unset the var), so invalid_request is correct. return createAssistantAPIErrorMessage({ error: 'invalid_request', content: hasStoredOAuth ? ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH : ORG_DISABLED_ERROR_MESSAGE_ENV_KEY, }) } } if ( error instanceof Error && error.message.toLowerCase().includes('x-api-key') ) { // In CCR mode, auth is via JWTs - this is likely a transient network issue if (isCCRMode()) { return createAssistantAPIErrorMessage({ error: 'authentication_failed', content: CCR_AUTH_ERROR_MESSAGE, }) } // Check if the API key is from an external source const { source } = getAnthropicApiKeyWithSource() const isExternalSource = source === 'ANTHROPIC_API_KEY' || source === 'apiKeyHelper' return createAssistantAPIErrorMessage({ error: 'authentication_failed', content: isExternalSource ? INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL : INVALID_API_KEY_ERROR_MESSAGE, }) } // Check for OAuth token revocation error if ( error instanceof APIError && error.status === 403 && error.message.includes('OAuth token has been revoked') ) { return createAssistantAPIErrorMessage({ error: 'authentication_failed', content: getTokenRevokedErrorMessage(), }) } // Check for OAuth organization not allowed error if ( error instanceof APIError && (error.status === 401 || error.status === 403) && error.message.includes( 'OAuth authentication is currently not allowed for this organization', ) ) { return createAssistantAPIErrorMessage({ error: 'authentication_failed', content: getOauthOrgNotAllowedErrorMessage(), }) } // Generic handler for other 401/403 authentication errors if ( error instanceof APIError && (error.status === 401 || error.status === 403) ) { // In CCR mode, auth is via JWTs - this is likely a transient network issue if (isCCRMode()) { return createAssistantAPIErrorMessage({ error: 'authentication_failed', content: CCR_AUTH_ERROR_MESSAGE, }) } return createAssistantAPIErrorMessage({ error: 'authentication_failed', content: getIsNonInteractiveSession() ? `Failed to authenticate. ${API_ERROR_MESSAGE_PREFIX}: ${error.message}` : `Please run /login · ${API_ERROR_MESSAGE_PREFIX}: ${error.message}`, }) } // Bedrock errors like "403 You don't have access to the model with the specified model ID." // don't contain the actual model ID if ( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && error instanceof Error && error.message.toLowerCase().includes('model id') ) { const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model' const fallbackSuggestion = get3PModelFallbackSuggestion(model) return createAssistantAPIErrorMessage({ content: fallbackSuggestion ? `${API_ERROR_MESSAGE_PREFIX} (${model}): ${error.message}. Try ${switchCmd} to switch to ${fallbackSuggestion}.` : `${API_ERROR_MESSAGE_PREFIX} (${model}): ${error.message}. Run ${switchCmd} to pick a different model.`, error: 'invalid_request', }) } // 404 Not Found — usually means the selected model doesn't exist or isn't // available. Guide the user to /model so they can pick a valid one. // For 3P users, suggest a specific fallback model they can try. if (error instanceof APIError && error.status === 404) { const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model' const fallbackSuggestion = get3PModelFallbackSuggestion(model) return createAssistantAPIErrorMessage({ content: fallbackSuggestion ? `The model ${model} is not available on your ${getAPIProvider()} deployment. Try ${switchCmd} to switch to ${fallbackSuggestion}, or ask your admin to enable this model.` : `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.`, error: 'invalid_request', }) } // Connection errors (non-timeout) — use formatAPIError for detailed messages if (error instanceof APIConnectionError) { return createAssistantAPIErrorMessage({ content: `${API_ERROR_MESSAGE_PREFIX}: ${formatAPIError(error)}`, error: 'unknown', }) } if (error instanceof Error) { return createAssistantAPIErrorMessage({ content: `${API_ERROR_MESSAGE_PREFIX}: ${error.message}`, error: 'unknown', }) } return createAssistantAPIErrorMessage({ content: API_ERROR_MESSAGE_PREFIX, error: 'unknown', }) } /** * For 3P users, suggest a fallback model when the selected model is unavailable. * Returns a model name suggestion, or undefined if no suggestion is applicable. */ function get3PModelFallbackSuggestion(model: string): string | undefined { if (getAPIProvider() === 'firstParty') { return undefined } // @[MODEL LAUNCH]: Add a fallback suggestion chain for the new model → previous version for 3P const m = model.toLowerCase() // If the failing model looks like an Opus 4.6 variant, suggest the default Opus (4.1 for 3P) if (m.includes('opus-4-6') || m.includes('opus_4_6')) { return getModelStrings().opus41 } // If the failing model looks like a Sonnet 4.6 variant, suggest Sonnet 4.5 if (m.includes('sonnet-4-6') || m.includes('sonnet_4_6')) { return getModelStrings().sonnet45 } // If the failing model looks like a Sonnet 4.5 variant, suggest Sonnet 4 if (m.includes('sonnet-4-5') || m.includes('sonnet_4_5')) { return getModelStrings().sonnet40 } return undefined } /** * Classifies an API error into a specific error type for analytics tracking. * Returns a standardized error type string suitable for Datadog tagging. */ export function classifyAPIError(error: unknown): string { // Aborted requests if (error instanceof Error && error.message === 'Request was aborted.') { return 'aborted' } // Timeout errors if ( error instanceof APIConnectionTimeoutError || (error instanceof APIConnectionError && error.message.toLowerCase().includes('timeout')) ) { return 'api_timeout' } // Check for repeated 529 errors if ( error instanceof Error && error.message.includes(REPEATED_529_ERROR_MESSAGE) ) { return 'repeated_529' } // Check for emergency capacity off switch if ( error instanceof Error && error.message.includes(CUSTOM_OFF_SWITCH_MESSAGE) ) { return 'capacity_off_switch' } // Rate limiting if (error instanceof APIError && error.status === 429) { return 'rate_limit' } // Server overload (529) if ( error instanceof APIError && (error.status === 529 || error.message?.includes('"type":"overloaded_error"')) ) { return 'server_overload' } // Prompt/content size errors if ( error instanceof Error && error.message .toLowerCase() .includes(PROMPT_TOO_LONG_ERROR_MESSAGE.toLowerCase()) ) { return 'prompt_too_long' } // PDF errors if ( error instanceof Error && /maximum of \d+ PDF pages/.test(error.message) ) { return 'pdf_too_large' } if ( error instanceof Error && error.message.includes('The PDF specified is password protected') ) { return 'pdf_password_protected' } // Image size errors if ( error instanceof APIError && error.status === 400 && error.message.includes('image exceeds') && error.message.includes('maximum') ) { return 'image_too_large' } // Many-image dimension errors if ( error instanceof APIError && error.status === 400 && error.message.includes('image dimensions exceed') && error.message.includes('many-image') ) { return 'image_too_large' } // Tool use errors (400) if ( error instanceof APIError && error.status === 400 && error.message.includes( '`tool_use` ids were found without `tool_result` blocks immediately after', ) ) { return 'tool_use_mismatch' } if ( error instanceof APIError && error.status === 400 && error.message.includes('unexpected `tool_use_id` found in `tool_result`') ) { return 'unexpected_tool_result' } if ( error instanceof APIError && error.status === 400 && error.message.includes('`tool_use` ids must be unique') ) { return 'duplicate_tool_use_id' } // Invalid model errors (400) if ( error instanceof APIError && error.status === 400 && error.message.toLowerCase().includes('invalid model name') ) { return 'invalid_model' } // Credit/billing errors if ( error instanceof Error && error.message .toLowerCase() .includes(CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE.toLowerCase()) ) { return 'credit_balance_low' } // Authentication errors if ( error instanceof Error && error.message.toLowerCase().includes('x-api-key') ) { return 'invalid_api_key' } if ( error instanceof APIError && error.status === 403 && error.message.includes('OAuth token has been revoked') ) { return 'token_revoked' } if ( error instanceof APIError && (error.status === 401 || error.status === 403) && error.message.includes( 'OAuth authentication is currently not allowed for this organization', ) ) { return 'oauth_org_not_allowed' } // Generic auth errors if ( error instanceof APIError && (error.status === 401 || error.status === 403) ) { return 'auth_error' } // Bedrock-specific errors if ( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && error instanceof Error && error.message.toLowerCase().includes('model id') ) { return 'bedrock_model_access' } // Status code based fallbacks if (error instanceof APIError) { const status = error.status if (status >= 500) return 'server_error' if (status >= 400) return 'client_error' } // Connection errors - check for SSL/TLS issues first if (error instanceof APIConnectionError) { const connectionDetails = extractConnectionErrorDetails(error) if (connectionDetails?.isSSLError) { return 'ssl_cert_error' } return 'connection_error' } return 'unknown' } export function categorizeRetryableAPIError( error: APIError, ): SDKAssistantMessageError { if ( error.status === 529 || error.message?.includes('"type":"overloaded_error"') ) { return 'rate_limit' } if (error.status === 429) { return 'rate_limit' } if (error.status === 401 || error.status === 403) { return 'authentication_failed' } if (error.status !== undefined && error.status >= 408) { return 'server_error' } return 'unknown' } export function getErrorMessageIfRefusal( stopReason: BetaStopReason | null, model: string, ): AssistantMessage | undefined { if (stopReason !== 'refusal') { return } logEvent('tengu_refusal_api_response', {}) const baseMessage = getIsNonInteractiveSession() ? `${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.` : `${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.` const modelSuggestion = model !== 'claude-sonnet-4-20250514' ? ' If you are seeing this refusal repeatedly, try running /model claude-sonnet-4-20250514 to switch models.' : '' return createAssistantAPIErrorMessage({ content: baseMessage + modelSuggestion, error: 'invalid_request', }) }