mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
1404 lines
49 KiB
TypeScript
1404 lines
49 KiB
TypeScript
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 { BUSINESS_ERROR_CODES } from 'src/constants/businessErrors.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'
|
|
import { StreamWatchdogTimeoutError } from './streamWatchdog.js'
|
|
|
|
// Presentation only: classifiers, retries and diagnostic metadata keep the
|
|
// original SDK error. Decode envelopes structurally so escaped quotes/newlines
|
|
// survive, and only suppress HTML documents (not markup mentioned in prose).
|
|
function getAPIErrorDetail(error: APIError): string {
|
|
const raw = error.message ?? ''
|
|
const prefix = error.status === undefined ? '' : `${error.status} `
|
|
const stripped = prefix && raw.startsWith(prefix) ? raw.slice(prefix.length) : raw
|
|
let body: unknown = error.error
|
|
if ((body === undefined || body === null) && /^\s*(?:\{|\[|")/.test(stripped)) {
|
|
try {
|
|
body = JSON.parse(stripped)
|
|
} catch {
|
|
// A malformed envelope is still useful as plain-text diagnostic output.
|
|
}
|
|
}
|
|
let detail = stripped
|
|
if (body !== undefined && body !== null) {
|
|
let current: unknown = body
|
|
detail = 'Request failed'
|
|
for (let depth = 0; depth < 5; depth++) {
|
|
if (typeof current === 'string') {
|
|
detail = current
|
|
break
|
|
}
|
|
if (!current || typeof current !== 'object' || Array.isArray(current)) break
|
|
const record = current as Record<string, unknown>
|
|
if (typeof record.message === 'string') {
|
|
detail = record.message
|
|
break
|
|
}
|
|
current = record.error
|
|
}
|
|
}
|
|
if (/^\s*(?:<!doctype\s+html\b|<html\b|<head\b|<body\b)/i.test(detail)) {
|
|
return 'Received an HTML error response from the server.'
|
|
}
|
|
return detail.trimEnd()
|
|
}
|
|
|
|
function formatAPIErrorForDisplay(error: APIError): string {
|
|
const detail = getAPIErrorDetail(error)
|
|
return error.status === undefined ? detail : `${error.status}${detail ? ` ${detail}` : ''}`
|
|
}
|
|
|
|
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'
|
|
|
|
/**
|
|
* Providers word context-overflow rejections differently, and some gateways
|
|
* even wrap them in a 401 (e.g. Kimi's "k3-256k supports only 256K context").
|
|
* Anything matched here is normalized to PROMPT_TOO_LONG_ERROR_MESSAGE so the
|
|
* compact retry loop and the UI can key on one string — without this, a
|
|
* third-party overflow surfaces as "Please run /login" and the session is
|
|
* unrecoverable (#1162).
|
|
*/
|
|
const CONTEXT_OVERFLOW_PATTERNS: RegExp[] = [
|
|
/prompt is too long/i,
|
|
/input is too long for requested model/i,
|
|
/context_length_exceeded/i,
|
|
/maximum context length/i,
|
|
/exceeds? the context window/i,
|
|
/context window exceeded/i,
|
|
/supports only \d+\s*k?\s*(?:tokens?\s+of\s+)?context/i,
|
|
]
|
|
|
|
export function isContextOverflowErrorText(text: string): boolean {
|
|
return CONTEXT_OVERFLOW_PATTERNS.some(pattern => pattern.test(text))
|
|
}
|
|
|
|
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 in Anthropic or OpenAI-compatible format. Requested tokens include
|
|
* the completion allowance so compaction also leaves room for the response.
|
|
* 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 anthropic = rawMessage.match(
|
|
/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i,
|
|
)
|
|
const openAI = anthropic ? null : rawMessage.match(
|
|
/maximum context length is\s+(\d+)\s+tokens?\.\s*(?:however,\s*)you requested\s+(\d+)\s+tokens?\b/i,
|
|
)
|
|
const actualTokens = Number(anthropic?.[1] ?? openAI?.[2])
|
|
const limitTokens = Number(anthropic?.[2] ?? openAI?.[1])
|
|
const valid = Number.isSafeInteger(actualTokens) && actualTokens > 0 &&
|
|
Number.isSafeInteger(limitTokens) && limitTokens > 0
|
|
return {
|
|
actualTokens: valid ? actualTokens : undefined,
|
|
limitTokens: valid ? limitTokens : 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 getImageUnsupportedErrorMessage(): string {
|
|
return getIsNonInteractiveSession()
|
|
? 'This model does not support images. Continue with text, or switch to a vision-capable model and send the image again.'
|
|
: 'This model does not support images. Double press esc to go back, switch to a vision-capable model, or continue with text.'
|
|
}
|
|
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 isUnsupportedImageInputErrorMessage(message: string): boolean {
|
|
const raw = message.toLowerCase()
|
|
if (!raw.includes('image')) return false
|
|
if (isOpenAIImageUrlTextOnlySchemaError(raw)) {
|
|
return true
|
|
}
|
|
return (
|
|
raw.includes('not support') ||
|
|
raw.includes('not supported') ||
|
|
raw.includes('unsupported') ||
|
|
raw.includes('vision') ||
|
|
raw.includes('multimodal') ||
|
|
raw.includes('multi-modal') ||
|
|
raw.includes('modality')
|
|
)
|
|
}
|
|
|
|
function isOpenAIImageUrlTextOnlySchemaError(raw: string): boolean {
|
|
if (!raw.includes('image_url')) return false
|
|
if (
|
|
raw.includes('not allowed') ||
|
|
raw.includes('not permitted') ||
|
|
raw.includes('disallowed') ||
|
|
raw.includes('forbidden')
|
|
) {
|
|
return true
|
|
}
|
|
if (!raw.includes('text')) return false
|
|
return (
|
|
raw.includes('expected') ||
|
|
raw.includes('input should be') ||
|
|
raw.includes('not one of') ||
|
|
raw.includes('permitted') ||
|
|
raw.includes('received') ||
|
|
raw.includes('unknown variant') ||
|
|
raw.includes('invalid value') ||
|
|
raw.includes('invalid type') ||
|
|
raw.includes('valid enumeration') ||
|
|
raw.includes('only text')
|
|
)
|
|
}
|
|
|
|
// Deep-walk a request payload looking for image blocks. Images can sit at the
|
|
// top level of a user message or nested inside a tool_result's content, and
|
|
// the payloads here are { type, message: { content } } wrappers, so walk all
|
|
// object values rather than only `content`.
|
|
function messagesContainImageBlock(messages: readonly unknown[]): boolean {
|
|
const walk = (value: unknown): boolean => {
|
|
if (Array.isArray(value)) {
|
|
return value.some(walk)
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const record = value as Record<string, unknown>
|
|
if (record.type === 'image') return true
|
|
return Object.values(record).some(walk)
|
|
}
|
|
return false
|
|
}
|
|
return walk(messages)
|
|
}
|
|
|
|
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(),
|
|
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_TOO_LARGE,
|
|
})
|
|
}
|
|
|
|
// Custom/Anthropic-compatible providers often reject image blocks with
|
|
// provider-specific wording when the selected model is text-only. Convert it
|
|
// to a known synthetic error so normalizeMessagesForAPI can strip the image
|
|
// from later turns instead of poisoning the whole session.
|
|
if (
|
|
error instanceof Error &&
|
|
isUnsupportedImageInputErrorMessage(error.message) &&
|
|
(!(error instanceof APIError) || error.status === 400 || error.status === 422)
|
|
) {
|
|
return createAssistantAPIErrorMessage({
|
|
content: getImageUnsupportedErrorMessage(),
|
|
error: 'invalid_request',
|
|
errorDetails: error.message,
|
|
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
|
|
sourceModel: model,
|
|
})
|
|
}
|
|
|
|
// 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',
|
|
})
|
|
}
|
|
const detail = getAPIErrorDetail(error)
|
|
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 context-overflow errors (Vertex returns 413, direct API returns
|
|
// 400, some third-party gateways wrap them in a 401 with their own wording).
|
|
// This must stay ahead of the generic 401 handler below so a wrapped
|
|
// overflow isn't misreported as an authentication failure.
|
|
if (error instanceof Error && isContextOverflowErrorText(error.message)) {
|
|
// 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,
|
|
businessErrorCode: BUSINESS_ERROR_CODES.PROMPT_TOO_LONG,
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
businessErrorCode: BUSINESS_ERROR_CODES.PDF_TOO_LARGE,
|
|
})
|
|
}
|
|
|
|
// 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',
|
|
businessErrorCode: BUSINESS_ERROR_CODES.PDF_PASSWORD_PROTECTED,
|
|
})
|
|
}
|
|
|
|
// 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',
|
|
businessErrorCode: BUSINESS_ERROR_CODES.PDF_INVALID,
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_TOO_LARGE,
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_TOO_LARGE,
|
|
})
|
|
}
|
|
|
|
// 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',
|
|
businessErrorCode: BUSINESS_ERROR_CODES.AUTO_MODE_UNAVAILABLE,
|
|
})
|
|
}
|
|
|
|
// 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',
|
|
businessErrorCode: BUSINESS_ERROR_CODES.REQUEST_TOO_LARGE,
|
|
})
|
|
}
|
|
|
|
// 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}: ${formatAPIErrorForDisplay(error)}`
|
|
: `Please run /login · ${API_ERROR_MESSAGE_PREFIX}: ${formatAPIErrorForDisplay(error)}`,
|
|
})
|
|
}
|
|
|
|
// 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',
|
|
})
|
|
}
|
|
|
|
if (error instanceof StreamWatchdogTimeoutError) {
|
|
return createAssistantAPIErrorMessage({
|
|
content: `${API_ERROR_MESSAGE_PREFIX}: ${error.message}`,
|
|
error: 'server_error',
|
|
errorDetails: JSON.stringify(error.toDiagnosticData()),
|
|
})
|
|
}
|
|
|
|
// Fallback for image rejections with unrecognized wording. The wording
|
|
// classifier above can't enumerate every provider/gateway phrasing, and a
|
|
// miss used to poison the session: the rejected image stayed in history and
|
|
// every later turn re-failed with the same 400. When a 400/422 lands on a
|
|
// request that actually carried image blocks and no more specific classifier
|
|
// matched (context overflow, PDF, image size, auth, 404 all handled above),
|
|
// treat it as an image rejection so the image is stripped from later turns.
|
|
// The strip is gated to sourceModel, so a false positive only affects the
|
|
// exact model that failed — switching models replays the image.
|
|
if (
|
|
error instanceof APIError &&
|
|
(error.status === 400 || error.status === 422) &&
|
|
options?.messagesForAPI &&
|
|
messagesContainImageBlock(options.messagesForAPI)
|
|
) {
|
|
return createAssistantAPIErrorMessage({
|
|
content: getImageUnsupportedErrorMessage(),
|
|
error: 'invalid_request',
|
|
errorDetails: error.message,
|
|
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
|
|
sourceModel: model,
|
|
})
|
|
}
|
|
|
|
// 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 instanceof APIError ? formatAPIErrorForDisplay(error) : 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.7 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',
|
|
})
|
|
}
|