π File detail
services/api/errors.ts
π― Use case
This file lives under βservices/β, which covers long-lived services (LSP, MCP, OAuth, tool execution, memory, compaction, voice, settings sync, β¦). On the API surface it exposes API_ERROR_MESSAGE_PREFIX, startsWithApiErrorPrefix, PROMPT_TOO_LONG_ERROR_MESSAGE, isPromptTooLongMessage, and parsePromptTooLongTokenCounts (and more) β mainly functions, hooks, or classes. Dependencies touch @anthropic-ai and src. It composes internal code from bootstrap, constants, utils, analytics, and claudeAiLimits (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { APIConnectionError, APIConnectionTimeoutError, APIError, } from '@anthropic-ai/sdk'
π€ Exports (heuristic)
API_ERROR_MESSAGE_PREFIXstartsWithApiErrorPrefixPROMPT_TOO_LONG_ERROR_MESSAGEisPromptTooLongMessageparsePromptTooLongTokenCountsgetPromptTooLongTokenGapisMediaSizeErrorisMediaSizeErrorMessageCREDIT_BALANCE_TOO_LOW_ERROR_MESSAGEINVALID_API_KEY_ERROR_MESSAGEINVALID_API_KEY_ERROR_MESSAGE_EXTERNALORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTHORG_DISABLED_ERROR_MESSAGE_ENV_KEYTOKEN_REVOKED_ERROR_MESSAGECCR_AUTH_ERROR_MESSAGEREPEATED_529_ERROR_MESSAGECUSTOM_OFF_SWITCH_MESSAGEAPI_TIMEOUT_ERROR_MESSAGEgetPdfTooLargeErrorMessagegetPdfPasswordProtectedErrorMessagegetPdfInvalidErrorMessagegetImageTooLargeErrorMessagegetRequestTooLargeErrorMessageOAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGEgetTokenRevokedErrorMessagegetOauthOrgNotAllowedErrorMessageisValidAPIMessageextractUnknownErrorFormatgetAssistantMessageFromErrorclassifyAPIErrorcategorizeRetryableAPIErrorgetErrorMessageIfRefusal
π External import roots
Package roots from from "β¦" (relative paths omitted).
@anthropic-aisrc
π₯οΈ Source preview
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',
})
}