diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1bbb216b1a..8ffcc1b6a7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6f7224e972..658abe3180 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 4bf28efd4f..e98a6e5b4d 100644 --- a/coderd/x/chatd/chaterror/classify.go +++ b/coderd/x/chatd/chaterror/classify.go @@ -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, diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 0e2e008bb8..10a4709e63 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -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 { diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index fef3ba78fa..9074648fc4 100644 --- a/coderd/x/chatd/chaterror/message.go +++ b/coderd/x/chatd/chaterror/message.go @@ -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, diff --git a/coderd/x/chatd/chaterror/message_test.go b/coderd/x/chatd/chaterror/message_test.go index 94bf14bd13..6cd90f7587 100644 --- a/coderd/x/chatd/chaterror/message_test.go +++ b/coderd/x/chatd/chaterror/message_test.go @@ -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 { diff --git a/coderd/x/chatd/chaterror/signals.go b/coderd/x/chatd/chaterror/signals.go index 8dad919127..d8cb8cfb6e 100644 --- a/coderd/x/chatd/chaterror/signals.go +++ b/coderd/x/chatd/chaterror/signals.go @@ -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} ) diff --git a/codersdk/chats.go b/codersdk/chats.go index bcf235f590..3251aacff7 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -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 diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index f475d8482d..0c2f9f740d 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -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). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ea8f19c4bf..a9692f2157 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a42e1489ff..91b746391e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 83024550f3..c61fe34bc4 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -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( @@ -1129,7 +1138,26 @@ export const ConversationTimeline = memo( 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(); + const ids = new Set(); + 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>(new Map()); const registerSentinel = (messageId: number, el: HTMLDivElement | null) => { if (el) { @@ -1258,6 +1286,7 @@ export const ConversationTimeline = memo( 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} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx index 924bee9b0c..89629b4c6f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx @@ -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={ diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index d9ea6f6e59..75d316b49b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -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"; } diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 88e958b725..2a0924de61 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -28,6 +28,7 @@ import { selectMessagesByID, selectOrderedMessageIDs, selectQueuedMessages, + selectStreamError, useChatSelector, type useChatStore, } from "./ChatConversation/chatStore"; @@ -71,6 +72,8 @@ export const ChatPageTimeline: FC = ({ 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 = ({ urlTransform={urlTransform} mcpServers={mcpServers} showDesktopPreviews={false} + imageErrorKind={imageErrorKind} />