1208 lines
41 KiB
TypeScript
1208 lines
41 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 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',
|
|
})
|
|
}
|