mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(coderd/x/chatd): add image_too_large error classification and UI highlighting
Adds a new ChatErrorKindImageTooLarge error kind for when users upload images that exceed the provider's maximum allowed dimensions (e.g. Anthropic's 2000 pixel limit for multi-image requests). Backend: - New ChatErrorKindImageTooLarge constant and classification patterns - Detects errors containing 'image dimensions exceed' or 'max allowed size for many-image' and classifies them as non-retryable - User-friendly terminal and retry messages explaining the issue and how to fix it Frontend: - 'Image too large' error title in the status callout - ConversationTimeline identifies user messages containing image attachments when an image_too_large error occurs - Affected user message bubbles get a red error border highlight to guide the user to the message they need to edit
This commit is contained in:
Generated
+4
-2
@@ -16603,7 +16603,8 @@ const docTemplate = `{
|
||||
"config",
|
||||
"usage_limit",
|
||||
"missing_key",
|
||||
"provider_disabled"
|
||||
"provider_disabled",
|
||||
"image_too_large"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ChatErrorKindGeneric",
|
||||
@@ -16615,7 +16616,8 @@ const docTemplate = `{
|
||||
"ChatErrorKindConfig",
|
||||
"ChatErrorKindUsageLimit",
|
||||
"ChatErrorKindMissingKey",
|
||||
"ChatErrorKindProviderDisabled"
|
||||
"ChatErrorKindProviderDisabled",
|
||||
"ChatErrorKindImageTooLarge"
|
||||
]
|
||||
},
|
||||
"codersdk.ChatFileMetadata": {
|
||||
|
||||
Generated
+4
-2
@@ -14941,7 +14941,8 @@
|
||||
"config",
|
||||
"usage_limit",
|
||||
"missing_key",
|
||||
"provider_disabled"
|
||||
"provider_disabled",
|
||||
"image_too_large"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ChatErrorKindGeneric",
|
||||
@@ -14953,7 +14954,8 @@
|
||||
"ChatErrorKindConfig",
|
||||
"ChatErrorKindUsageLimit",
|
||||
"ChatErrorKindMissingKey",
|
||||
"ChatErrorKindProviderDisabled"
|
||||
"ChatErrorKindProviderDisabled",
|
||||
"ChatErrorKindImageTooLarge"
|
||||
]
|
||||
},
|
||||
"codersdk.ChatFileMetadata": {
|
||||
|
||||
@@ -201,6 +201,7 @@ func Classify(err error) ClassifiedError {
|
||||
usageLimitMatch := containsAny(lower, usageLimitPatterns...)
|
||||
authStrong := statusCode == 401 || containsAny(lower, authStrongPatterns...)
|
||||
configMatch := containsAny(lower, configPatterns...)
|
||||
imageTooLargeMatch := containsAny(lower, imageTooLargePatterns...)
|
||||
authWeak := statusCode == 403 || containsAny(lower, authWeakPatterns...)
|
||||
rateLimitMatch := statusCode == 429 || containsAny(lower, rateLimitPatterns...)
|
||||
timeoutPatternMatch := containsAny(lower, timeoutPatterns...)
|
||||
@@ -264,6 +265,11 @@ func Classify(err error) ClassifiedError {
|
||||
kind: codersdk.ChatErrorKindTimeout,
|
||||
retryable: !deadline,
|
||||
},
|
||||
{
|
||||
match: imageTooLargeMatch,
|
||||
kind: codersdk.ChatErrorKindImageTooLarge,
|
||||
retryable: false,
|
||||
},
|
||||
{
|
||||
match: configMatch,
|
||||
kind: codersdk.ChatErrorKindConfig,
|
||||
|
||||
@@ -298,6 +298,28 @@ func TestClassify(t *testing.T) {
|
||||
StatusCode: 503,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AnthropicImageDimensionsTooLarge",
|
||||
err: xerrors.New("provider request failed: anthropic error 400: messages.112.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"),
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "One or more images in the conversation exceed the provider's maximum allowed dimensions. Edit the message containing the oversized image and remove or resize it to continue.",
|
||||
Kind: codersdk.ChatErrorKindImageTooLarge,
|
||||
Provider: "anthropic",
|
||||
Retryable: false,
|
||||
StatusCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GenericImageDimensionsError",
|
||||
err: xerrors.New("image dimensions exceed limit"),
|
||||
want: chaterror.ClassifiedError{
|
||||
Message: "One or more images in the conversation exceed the provider's maximum allowed dimensions. Edit the message containing the oversized image and remove or resize it to continue.",
|
||||
Kind: codersdk.ChatErrorKindImageTooLarge,
|
||||
Provider: "",
|
||||
Retryable: false,
|
||||
StatusCode: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -444,6 +466,8 @@ func TestClassify_PatternCoverage(t *testing.T) {
|
||||
{name: "Status408", err: "status 408", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
|
||||
{name: "Status500", err: "status 500", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true},
|
||||
{name: "ProviderDisabledLiteral", err: "provider_disabled", wantKind: codersdk.ChatErrorKindProviderDisabled, wantRetry: false},
|
||||
{name: "ImageDimensionsExceedLiteral", err: "image dimensions exceed", wantKind: codersdk.ChatErrorKindImageTooLarge, wantRetry: false},
|
||||
{name: "MaxAllowedSizeForManyImageLiteral", err: "max allowed size for many-image", wantKind: codersdk.ChatErrorKindImageTooLarge, wantRetry: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -64,6 +64,9 @@ func terminalMessage(classified ClassifiedError) string {
|
||||
" Contact your Coder administrator.",
|
||||
displayName,
|
||||
)
|
||||
case codersdk.ChatErrorKindImageTooLarge:
|
||||
return "One or more images in the conversation exceed the provider's maximum allowed dimensions." +
|
||||
" Edit the message containing the oversized image and remove or resize it to continue."
|
||||
default:
|
||||
if !classified.Retryable && classified.StatusCode == 0 {
|
||||
return "The chat request failed unexpectedly."
|
||||
@@ -109,6 +112,8 @@ func retryMessage(classified ClassifiedError) string {
|
||||
"The %s provider has been disabled by an administrator.",
|
||||
displayName,
|
||||
)
|
||||
case codersdk.ChatErrorKindImageTooLarge:
|
||||
return "An image exceeds the maximum allowed dimensions."
|
||||
default:
|
||||
return stringutil.Capitalize(fmt.Sprintf(
|
||||
"%s returned an unexpected error.", subject,
|
||||
|
||||
@@ -97,6 +97,13 @@ func TestTerminalMessage(t *testing.T) {
|
||||
retryable: false,
|
||||
want: "This conversation was started with an API key that is no longer available. Send your message again to continue.",
|
||||
},
|
||||
{
|
||||
name: "ImageTooLarge",
|
||||
kind: codersdk.ChatErrorKindImageTooLarge,
|
||||
provider: "",
|
||||
retryable: false,
|
||||
want: "One or more images in the conversation exceed the provider's maximum allowed dimensions. Edit the message containing the oversized image and remove or resize it to continue.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -84,6 +84,7 @@ var (
|
||||
"malformed configuration",
|
||||
}
|
||||
genericRetryablePatterns = []string{"server error", "internal server error"}
|
||||
imageTooLargePatterns = []string{"image dimensions exceed", "max allowed size for many-image"}
|
||||
interruptedPatterns = []string{"chat interrupted", "request interrupted", "operation interrupted"}
|
||||
providerDisabledPatterns = []string{aibridge.ErrorCodeProviderDisabled}
|
||||
)
|
||||
|
||||
@@ -1535,6 +1535,7 @@ const (
|
||||
ChatErrorKindUsageLimit ChatErrorKind = "usage_limit"
|
||||
ChatErrorKindMissingKey ChatErrorKind = "missing_key"
|
||||
ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled"
|
||||
ChatErrorKindImageTooLarge ChatErrorKind = "image_too_large"
|
||||
)
|
||||
|
||||
// AllChatErrorKinds contains every ChatErrorKind value.
|
||||
@@ -1550,6 +1551,7 @@ var AllChatErrorKinds = []ChatErrorKind{
|
||||
ChatErrorKindUsageLimit,
|
||||
ChatErrorKindMissingKey,
|
||||
ChatErrorKindProviderDisabled,
|
||||
ChatErrorKindImageTooLarge,
|
||||
}
|
||||
|
||||
// ChatError represents a terminal chat error in persisted chat state or the
|
||||
|
||||
Generated
+7
-7
@@ -292,13 +292,13 @@ Status Code **200**
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|---------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `client_type` | `api`, `ui` |
|
||||
| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
|
||||
| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` |
|
||||
| `plan_mode` | `plan` |
|
||||
| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` |
|
||||
| Property | Value(s) |
|
||||
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `client_type` | `api`, `ui` |
|
||||
| `kind` | `auth`, `config`, `generic`, `image_too_large`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
|
||||
| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` |
|
||||
| `plan_mode` | `plan` |
|
||||
| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -2681,9 +2681,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value(s) |
|
||||
|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
|
||||
| Value(s) |
|
||||
|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `auth`, `config`, `generic`, `image_too_large`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
|
||||
|
||||
## codersdk.ChatFileMetadata
|
||||
|
||||
|
||||
Generated
+2
@@ -1967,6 +1967,7 @@ export type ChatErrorKind =
|
||||
| "auth"
|
||||
| "config"
|
||||
| "generic"
|
||||
| "image_too_large"
|
||||
| "missing_key"
|
||||
| "overloaded"
|
||||
| "provider_disabled"
|
||||
@@ -1979,6 +1980,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [
|
||||
"auth",
|
||||
"config",
|
||||
"generic",
|
||||
"image_too_large",
|
||||
"missing_key",
|
||||
"overloaded",
|
||||
"provider_disabled",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Fragment,
|
||||
memo,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -532,6 +533,7 @@ const ChatMessageItem = memo<{
|
||||
hideActions?: boolean;
|
||||
hasActiveStream?: boolean;
|
||||
isAwaitingFirstStreamChunk?: boolean;
|
||||
hasImageError?: boolean;
|
||||
|
||||
// When true, renders a gradient overlay inside the bubble
|
||||
// that fades text out toward the bottom. Used by the sticky
|
||||
@@ -561,6 +563,7 @@ const ChatMessageItem = memo<{
|
||||
hideActions = false,
|
||||
hasActiveStream = false,
|
||||
isAwaitingFirstStreamChunk = false,
|
||||
hasImageError = false,
|
||||
fadeFromBottom = false,
|
||||
onImplementPlan,
|
||||
onSendAskUserQuestionResponse,
|
||||
@@ -611,6 +614,7 @@ const ChatMessageItem = memo<{
|
||||
markdown={parsed.markdown}
|
||||
isEditing={editingMessageId === message.id}
|
||||
fadeFromBottom={fadeFromBottom}
|
||||
hasImageError={hasImageError}
|
||||
onImageClick={setPreviewImage}
|
||||
onTextFileClick={setPreviewText}
|
||||
/>
|
||||
@@ -776,6 +780,7 @@ const StickyUserMessage = memo<{
|
||||
) => void;
|
||||
editingMessageId?: number | null;
|
||||
isAfterEditingMessage?: boolean;
|
||||
hasImageError?: boolean;
|
||||
prevUserMessageId?: number;
|
||||
nextUserMessageId?: number;
|
||||
onJumpToUserMessage?: (messageId: number) => void;
|
||||
@@ -787,6 +792,7 @@ const StickyUserMessage = memo<{
|
||||
onEditUserMessage,
|
||||
editingMessageId,
|
||||
isAfterEditingMessage = false,
|
||||
hasImageError = false,
|
||||
prevUserMessageId,
|
||||
nextUserMessageId,
|
||||
onJumpToUserMessage,
|
||||
@@ -1020,6 +1026,7 @@ const StickyUserMessage = memo<{
|
||||
onEditUserMessage={handleEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
isAfterEditingMessage={isAfterEditingMessage}
|
||||
hasImageError={hasImageError}
|
||||
prevUserMessageId={prevUserMessageId}
|
||||
nextUserMessageId={nextUserMessageId}
|
||||
onJumpToUserMessage={onJumpToUserMessage}
|
||||
@@ -1065,6 +1072,7 @@ const StickyUserMessage = memo<{
|
||||
onEditUserMessage={handleEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
isAfterEditingMessage={isAfterEditingMessage}
|
||||
hasImageError={hasImageError}
|
||||
prevUserMessageId={prevUserMessageId}
|
||||
nextUserMessageId={nextUserMessageId}
|
||||
onJumpToUserMessage={onJumpToUserMessage}
|
||||
@@ -1112,6 +1120,7 @@ interface ConversationTimelineProps {
|
||||
showDesktopPreviews?: boolean;
|
||||
hasActiveStream?: boolean;
|
||||
isAwaitingFirstStreamChunk?: boolean;
|
||||
imageErrorKind?: string;
|
||||
}
|
||||
|
||||
export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
@@ -1129,7 +1138,26 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
showDesktopPreviews,
|
||||
hasActiveStream,
|
||||
isAwaitingFirstStreamChunk,
|
||||
imageErrorKind,
|
||||
}) => {
|
||||
// Identify user messages that contain image file parts so they
|
||||
// can be highlighted when an image_too_large error occurs.
|
||||
const imageErrorMessageIds = useMemo(() => {
|
||||
if (imageErrorKind !== "image_too_large") return new Set<number>();
|
||||
const ids = new Set<number>();
|
||||
for (const { message } of parsedMessages) {
|
||||
if (message.role !== "user") continue;
|
||||
const hasImage = message.content?.some(
|
||||
(part) =>
|
||||
part.type === "file" && part.media_type?.startsWith("image/"),
|
||||
);
|
||||
if (hasImage) {
|
||||
ids.add(message.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [imageErrorKind, parsedMessages]);
|
||||
|
||||
const sentinelsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const registerSentinel = (messageId: number, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
@@ -1258,6 +1286,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
onEditUserMessage={onEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
|
||||
hasImageError={imageErrorMessageIds.has(message.id)}
|
||||
prevUserMessageId={userNeighborsById.get(message.id)?.prevId}
|
||||
nextUserMessageId={userNeighborsById.get(message.id)?.nextId}
|
||||
onJumpToUserMessage={jumpToUserMessage}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const UserMessageContent: FC<{
|
||||
markdown: string;
|
||||
isEditing?: boolean;
|
||||
fadeFromBottom?: boolean;
|
||||
hasImageError?: boolean;
|
||||
onImageClick?: (src: string) => void;
|
||||
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
|
||||
}> = ({
|
||||
@@ -39,6 +40,7 @@ export const UserMessageContent: FC<{
|
||||
markdown,
|
||||
isEditing = false,
|
||||
fadeFromBottom = false,
|
||||
hasImageError = false,
|
||||
onImageClick,
|
||||
onTextFileClick,
|
||||
}) => {
|
||||
@@ -49,6 +51,9 @@ export const UserMessageContent: FC<{
|
||||
"rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow",
|
||||
isEditing &&
|
||||
"border-surface-secondary shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
|
||||
hasImageError &&
|
||||
!isEditing &&
|
||||
"border-border-error shadow-[0_0_0_2px_hsla(var(--border-error),0.4)]",
|
||||
fadeFromBottom && "relative overflow-hidden",
|
||||
)}
|
||||
style={
|
||||
|
||||
@@ -46,6 +46,8 @@ export const getErrorTitle = (
|
||||
return "Chat interrupted";
|
||||
case "provider_disabled":
|
||||
return "Provider disabled";
|
||||
case "image_too_large":
|
||||
return "Image too large";
|
||||
default:
|
||||
return mode === "retry" ? "Retrying request" : "Request failed";
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
selectMessagesByID,
|
||||
selectOrderedMessageIDs,
|
||||
selectQueuedMessages,
|
||||
selectStreamError,
|
||||
useChatSelector,
|
||||
type useChatStore,
|
||||
} from "./ChatConversation/chatStore";
|
||||
@@ -71,6 +72,8 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
|
||||
mcpServers,
|
||||
}) => {
|
||||
const [chatFullWidth] = useChatFullWidth();
|
||||
const streamError = useChatSelector(store, selectStreamError);
|
||||
const imageErrorKind = persistedError?.kind ?? streamError?.kind;
|
||||
const messagesByID = useChatSelector(store, selectMessagesByID);
|
||||
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
|
||||
const chatStatus = useChatSelector(store, selectChatStatus);
|
||||
@@ -127,6 +130,7 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
|
||||
urlTransform={urlTransform}
|
||||
mcpServers={mcpServers}
|
||||
showDesktopPreviews={false}
|
||||
imageErrorKind={imageErrorKind}
|
||||
/>
|
||||
<LiveStreamTail
|
||||
store={store}
|
||||
|
||||
Reference in New Issue
Block a user