-
Notifications
You must be signed in to change notification settings - Fork 8.5k
Expand file tree
/
Copy patherrors.ts
More file actions
1374 lines (1263 loc) · 47.9 KB
/
errors.ts
File metadata and controls
1374 lines (1263 loc) · 47.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
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'
import {
extractOpenAICategoryHost,
extractOpenAICategoryMarker,
isLocalhostLikeHost,
type OpenAICompatibilityFailureCategory,
} from './openaiErrorClassification.js'
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
function stripOpenAICompatibilityMetadata(message: string): string {
return message
.replace(/\s*\[openai_category=[a-z_]+\]\s*/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
}
function mapOpenAICompatibilityFailureToAssistantMessage(options: {
category: OpenAICompatibilityFailureCategory
model: string
rawMessage: string
host?: string
}): AssistantMessage {
const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model'
const compactHint = getIsNonInteractiveSession()
? 'Reduce prompt size or start a new session.'
: 'Run /compact or start a new session with /new.'
const isLocalhost = options.host === undefined || isLocalhostLikeHost(options.host)
switch (options.category) {
case 'localhost_resolution_failed':
case 'connection_refused':
return createAssistantAPIErrorMessage({
content: isLocalhost
? 'Could not connect to the local OpenAI-compatible provider. Ensure the local server is running, then use OPENAI_BASE_URL=http://127.0.0.1:11434/v1 for Ollama.'
: `Could not connect to the provider at ${options.host}. Verify OPENAI_BASE_URL is correct and that the host is reachable.`,
error: 'unknown',
})
case 'endpoint_not_found':
return createAssistantAPIErrorMessage({
content: isLocalhost
? 'Provider endpoint was not found. Confirm OPENAI_BASE_URL targets an OpenAI-compatible /v1 endpoint (for Ollama: http://127.0.0.1:11434/v1).'
: `Provider endpoint at ${options.host} returned 404. Verify OPENAI_BASE_URL is correct and that the selected model (${options.model}) is supported by this provider.`,
error: 'invalid_request',
})
case 'vision_not_supported':
return createAssistantAPIErrorMessage({
content: `The provider at ${options.host} returned 404 for a request containing images. The model (${options.model}) may not support image/vision inputs. Try removing images from your message, or ${switchCmd} to a vision-capable model.`,
error: 'invalid_request',
})
case 'model_not_found':
return createAssistantAPIErrorMessage({
content: `The selected model (${options.model}) is not available on this provider. Run ${switchCmd} to choose another model, or verify installed local models (for Ollama: ollama list).`,
error: 'invalid_request',
})
case 'auth_invalid':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Authentication failed for your OpenAI-compatible provider. Verify OPENAI_API_KEY and endpoint-specific auth requirements.`,
error: 'authentication_failed',
})
case 'rate_limited':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider rate limit reached. Retry in a few seconds.`,
error: 'rate_limit',
})
case 'request_timeout':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider request timed out. Local models may be loading or overloaded; retry shortly or increase API_TIMEOUT_MS.`,
error: 'unknown',
})
case 'context_overflow':
return createAssistantAPIErrorMessage({
content: `The conversation exceeded the provider context limit. ${compactHint}`,
error: 'invalid_request',
})
case 'tool_call_incompatible':
return createAssistantAPIErrorMessage({
content: `The selected provider/model rejected tool-calling payloads. Try ${switchCmd} to pick a tool-capable model or continue without tools.`,
error: 'invalid_request',
})
case 'malformed_provider_response':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider returned a malformed response. Confirm endpoint compatibility and check local proxy/network middleware.`,
error: 'unknown',
errorDetails: stripOpenAICompatibilityMetadata(options.rawMessage),
})
case 'provider_unavailable':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider is temporarily unavailable. Retry in a moment.`,
error: 'unknown',
})
case 'network_error':
case 'unknown':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`,
error: 'unknown',
})
default:
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`,
error: 'unknown',
})
}
}
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 function getCustomOffSwitchMessage(): string {
return getAPIProvider() === 'firstParty'
? 'Opus is experiencing high load, please use /model to switch to Sonnet'
: 'The API is experiencing high load, please try again shortly or use /model to switch models'
}
// Backward-compatible constant for string matching in error handlers
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 OpenClaude. 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(),
})
}
// OpenAI-compatible transport and HTTP failures include structured category
// markers from openaiShim.ts for actionable end-user remediation.
if (error instanceof APIError) {
const openaiCategory = extractOpenAICategoryMarker(error.message)
if (openaiCategory) {
return mapOpenAICompatibilityFailureToAssistantMessage({
category: openaiCategory,
model,
rawMessage: error.message,
host: extractOpenAICategoryHost(error.message),
})
}
}
// Check for emergency capacity off switch for Opus PAYG users
if (
error instanceof Error &&
error.message.includes(CUSTOM_OFF_SWITCH_MESSAGE)
) {
return createAssistantAPIErrorMessage({
content: getCustomOffSwitchMessage(),
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
const retryAfter = (error as APIError).headers?.get?.('retry-after')
const retryHint = retryAfter && !isNaN(Number(retryAfter))
? `Try again in ${retryAfter} seconds.`
: 'Try again in a few seconds.'
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Request rejected (429) · ${detail || 'this may be a temporary capacity issue'} — ${retryHint}`,
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 = `[internal] 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') &&
getAPIProvider() === 'firstParty'
) {
// 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(),
})
}