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