Download src/services/api/errors.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 42.5 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/services/api/errors.ts
- Command line
-
hf download hf://chenbhao/codev/src/services/api/errors.ts
-
curl -L -o errors.ts https://huggingface.co/chenbhao/codev/resolve/main/src/services/api/errors.ts
42.5 kB
| 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, | |
| }) | |
| } | |
| // Handle "exceeds the available context size" errors (alternative format used by some API providers) | |
| // This error format is functionally equivalent to "prompt is too long" and should trigger auto-compact | |
| if ( | |
| error instanceof Error && | |
| error.message.toLowerCase().includes('exceeds the available context size') | |
| ) { | |
| // 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) | |
| const provider = getAPIProvider() | |
| return createAssistantAPIErrorMessage({ | |
| content: fallbackSuggestion | |
| ? `The model ${model} is not available on your ${provider ?? 'configured'} 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', | |
| }) | |
| } | |