diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 66ba58bb75..93f9780dfa 100644 --- a/coderd/x/chatd/chaterror/classify.go +++ b/coderd/x/chatd/chaterror/classify.go @@ -11,6 +11,7 @@ import ( // underlying provider or runtime error. type ClassifiedError struct { Message string + Detail string Kind string Provider string Retryable bool @@ -78,7 +79,7 @@ func Classify(err error) ClassifiedError { structured := extractProviderErrorDetails(err) message := strings.TrimSpace(err.Error()) - if message == "" && structured.statusCode == 0 && structured.retryAfter <= 0 { + if message == "" && structured.detail == "" && structured.statusCode == 0 && structured.retryAfter <= 0 { return ClassifiedError{} } @@ -93,6 +94,7 @@ func Classify(err error) ClassifiedError { if canceled || interrupted { return normalizeClassification(ClassifiedError{ Message: "The request was canceled before it completed.", + Detail: structured.detail, Kind: KindGeneric, Provider: provider, StatusCode: statusCode, @@ -163,6 +165,7 @@ func Classify(err error) ClassifiedError { continue } return normalizeClassification(ClassifiedError{ + Detail: structured.detail, Kind: rule.kind, Provider: provider, Retryable: rule.retryable, @@ -172,6 +175,7 @@ func Classify(err error) ClassifiedError { } return normalizeClassification(ClassifiedError{ + Detail: structured.detail, Kind: KindGeneric, Provider: provider, StatusCode: statusCode, @@ -181,13 +185,15 @@ func Classify(err error) ClassifiedError { func normalizeClassification(classified ClassifiedError) ClassifiedError { classified.Message = strings.TrimSpace(classified.Message) + classified.Detail = normalizeClassificationDetail(classified.Detail) classified.Kind = strings.TrimSpace(classified.Kind) classified.Provider = normalizeProvider(classified.Provider) if classified.RetryAfter < 0 { classified.RetryAfter = 0 } if classified.Kind == "" && classified.Message == "" { - if classified.StatusCode == 0 && classified.RetryAfter <= 0 { + if classified.Detail == "" && classified.StatusCode == 0 && + classified.RetryAfter <= 0 { return ClassifiedError{} } classified.Kind = KindGeneric @@ -200,3 +206,17 @@ func normalizeClassification(classified ClassifiedError) ClassifiedError { } return classified } + +const maxClassificationDetailRunes = 500 + +func normalizeClassificationDetail(detail string) string { + detail = strings.TrimSpace(detail) + if detail == "" { + return "" + } + runes := []rune(detail) + if len(runes) <= maxClassificationDetailRunes { + return detail + } + return string(runes[:maxClassificationDetailRunes-1]) + "…" +} diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 0f1438decc..e7fb36e2e2 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -3,6 +3,7 @@ package chaterror_test import ( "context" "net/http" + "strings" "testing" "time" @@ -574,7 +575,7 @@ func TestWithProviderPreservesRetryAfter(t *testing.T) { t.Parallel() classified := chaterror.Classify(testProviderError( - "upstream failed", + "", 429, map[string]string{"Retry-After": "30"}, )) @@ -591,10 +592,75 @@ func TestWithProviderPreservesRetryAfter(t *testing.T) { }, enriched) } -func testProviderError(message string, statusCode int, headers map[string]string) error { +func TestClassify_UsesStructuredProviderDetailFromResponseDump(t *testing.T) { + t.Parallel() + + classified := chaterror.Classify(testProviderError( + "", + 400, + nil, + testProviderResponseDump(`{"error":{"type":"invalid_request_error","message":"Image exceeds 5 MB maximum."}}`), + )) + + require.Equal(t, chaterror.ClassifiedError{ + Message: "The AI provider returned an unexpected error (HTTP 400).", + Detail: "Image exceeds 5 MB maximum.", + Kind: chaterror.KindGeneric, + Provider: "", + Retryable: false, + StatusCode: 400, + }, classified) +} + +func TestClassify_FallsBackToProviderMessageForDetail(t *testing.T) { + t.Parallel() + + classified := chaterror.Classify(testProviderError( + " image exceeds 5 MB maximum ", + 400, + nil, + testProviderResponseDump("not-json"), + )) + + require.Equal(t, "image exceeds 5 MB maximum", classified.Detail) +} + +func TestClassify_TruncatesProviderDetail(t *testing.T) { + t.Parallel() + + detail := strings.Repeat("x", 510) + classified := chaterror.Classify(testProviderError( + "", + 400, + nil, + testProviderResponseDump(`{"error":{"message":"`+detail+`"}}`), + )) + + require.Len(t, []rune(classified.Detail), 500) + require.True(t, strings.HasSuffix(classified.Detail, "…")) +} + +func testProviderError( + message string, + statusCode int, + headers map[string]string, + responseBody ...[]byte, +) error { + var body []byte + if len(responseBody) > 0 { + body = responseBody[0] + } return &fantasy.ProviderError{ Message: message, StatusCode: statusCode, ResponseHeaders: headers, + ResponseBody: body, } } + +func testProviderResponseDump(body string) []byte { + return []byte(`HTTP/1.1 400 Bad Request +Content-Type: application/json + +` + body) +} diff --git a/coderd/x/chatd/chaterror/payload.go b/coderd/x/chatd/chaterror/payload.go index 695c217ef1..ca7dc214f4 100644 --- a/coderd/x/chatd/chaterror/payload.go +++ b/coderd/x/chatd/chaterror/payload.go @@ -12,6 +12,7 @@ func StreamErrorPayload(classified ClassifiedError) *codersdk.ChatStreamError { } return &codersdk.ChatStreamError{ Message: classified.Message, + Detail: classified.Detail, Kind: classified.Kind, Provider: classified.Provider, Retryable: classified.Retryable, diff --git a/coderd/x/chatd/chaterror/payload_test.go b/coderd/x/chatd/chaterror/payload_test.go index 615df09e45..c41bf7cd0d 100644 --- a/coderd/x/chatd/chaterror/payload_test.go +++ b/coderd/x/chatd/chaterror/payload_test.go @@ -28,6 +28,19 @@ func TestStreamErrorPayloadUsesNormalizedClassification(t *testing.T) { }, payload) } +func TestStreamErrorPayloadIncludesProviderDetail(t *testing.T) { + t.Parallel() + + payload := chaterror.StreamErrorPayload(chaterror.Classify(testProviderError( + "", + 400, + nil, + testProviderResponseDump(`{"error":{"message":"Image exceeds 5 MB maximum."}}`), + ))) + + require.Equal(t, "Image exceeds 5 MB maximum.", payload.Detail) +} + func TestStreamErrorPayloadNilForEmptyClassification(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chaterror/provider_error.go b/coderd/x/chatd/chaterror/provider_error.go index 68b350d610..d588d0f401 100644 --- a/coderd/x/chatd/chaterror/provider_error.go +++ b/coderd/x/chatd/chaterror/provider_error.go @@ -1,6 +1,8 @@ package chaterror import ( + "bytes" + "encoding/json" "errors" "net/http" "strconv" @@ -11,6 +13,7 @@ import ( ) type providerErrorDetails struct { + detail string statusCode int retryAfter time.Duration } @@ -22,11 +25,48 @@ func extractProviderErrorDetails(err error) providerErrorDetails { } return providerErrorDetails{ + detail: providerErrorDetail(providerErr), statusCode: providerErr.StatusCode, retryAfter: retryAfterFromHeaders(providerErr.ResponseHeaders), } } +func providerErrorDetail(providerErr *fantasy.ProviderError) string { + if detail := providerErrorResponseMessage(providerErr.ResponseBody); detail != "" { + return detail + } + return strings.TrimSpace(providerErr.Message) +} + +// providerErrorResponseMessage extracts error.message from the common +// provider error JSON envelope after stripping any dumped HTTP status +// line and headers. +func providerErrorResponseMessage(responseDump []byte) string { + if len(responseDump) == 0 || len(responseDump) > 64*1024 { + return "" + } + body := providerErrorResponseBody(responseDump) + var envelope struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return "" + } + return strings.TrimSpace(envelope.Error.Message) +} + +func providerErrorResponseBody(responseDump []byte) []byte { + if _, body, ok := bytes.Cut(responseDump, []byte("\r\n\r\n")); ok { + return body + } + if _, body, ok := bytes.Cut(responseDump, []byte("\n\n")); ok { + return body + } + return responseDump +} + func retryAfterFromHeaders(headers map[string]string) time.Duration { if len(headers) == 0 { return 0 diff --git a/codersdk/chats.go b/codersdk/chats.go index 6f8d05d1c8..4da8a35baf 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1200,6 +1200,9 @@ type ChatStreamStatus struct { type ChatStreamError struct { // Message is the normalized, user-facing error message. Message string `json:"message"` + // Detail is optional provider-specific context shown alongside the + // normalized error message when available. + Detail string `json:"detail,omitempty"` // Kind classifies the error for consistent client rendering. Kind string `json:"kind,omitempty"` // Provider identifies the upstream model provider when known. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bdc4892db7..e04e2463d5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2179,6 +2179,11 @@ export interface ChatStreamError { * Message is the normalized, user-facing error message. */ readonly message: string; + /** + * Detail is optional provider-specific context shown alongside the + * normalized error message when available. + */ + readonly detail?: string; /** * Kind classifies the error for consistent client rendering. */ diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index fb9968bc75..77c09e7db3 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -216,7 +216,7 @@ export const WithError: Story = { = ({ status }) => { if (status.phase === "retrying") { metadataItems.push(Attempt {status.attempt}); } - if (status.phase === "failed" && status.statusCode !== undefined) { - metadataItems.push(HTTP {status.statusCode}); - } return ( = ({ status }) => { > {status.title} - {status.message}{" "} - {statusURL && ( - - Status - + + {status.message}{" "} + {statusURL && ( + + Status + + )} + + {status.phase === "failed" && status.detail && ( + + {status.detail} + )} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index a6737fb61e..f1c69fe8b9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -81,7 +81,7 @@ export const TerminalOverloadedError: Story = { liveStatus: buildLiveStatus({ persistedError: { kind: "overloaded", - message: "Anthropic is currently overloaded.", + message: "Anthropic is temporarily overloaded (HTTP 529).", provider: "anthropic", retryable: true, statusCode: 529, @@ -94,11 +94,10 @@ export const TerminalOverloadedError: Story = { canvas.getByRole("heading", { name: /service overloaded/i }), ).toBeVisible(); expect( - canvas.getByText(/anthropic is currently overloaded./i), + canvas.getByText(/anthropic is temporarily overloaded \(http 529\)/i), ).toBeVisible(); expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument(); - expect(canvas.getByText(/http 529/i)).toBeVisible(); expect(canvas.getByRole("link", { name: /status/i })).toBeVisible(); expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); }, @@ -248,6 +247,34 @@ export const GenericErrorDoesNotShowUsageAction: Story = { }, }; +/** Provider detail renders as a muted secondary line under the main error. */ +export const GenericErrorShowsProviderDetail: Story = { + args: { + ...defaultArgs, + liveStatus: buildLiveStatus({ + streamError: { + kind: "generic", + message: "Anthropic returned an unexpected error (HTTP 400).", + detail: + "messages.0.content.1.image.source.base64: image exceeds 5 MB maximum.", + provider: "anthropic", + statusCode: 400, + retryable: false, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /request failed/i }), + ).toBeVisible(); + expect( + canvas.getByText(/anthropic returned an unexpected error \(http 400\)/i), + ).toBeVisible(); + expect(canvas.getByText(/image exceeds 5 mb maximum/i)).toBeVisible(); + }, +}; + /** Reconnecting keeps already-streamed content visible without a terminal footer. */ export const ReconnectingKeepsPartialOutputVisible: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index 45dd3d4930..2c06b4eb27 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -237,6 +237,7 @@ describe("setStreamError / clearStreamError", () => { store.setStreamError({ kind: "generic", message: "oops", + detail: "Image exceeds 5 MB maximum.", }); let notified = false; @@ -246,6 +247,7 @@ describe("setStreamError / clearStreamError", () => { store.setStreamError({ kind: "generic", message: "oops", + detail: "Image exceeds 5 MB maximum.", }); expect(notified).toBe(false); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 0607c8ba04..73d2c3224a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1710,6 +1710,7 @@ describe("useChatStore", () => { chat_id: chatID, error: { message: "Rate limit exceeded", + detail: "Image exceeds 5 MB maximum.", kind: "rate_limit", provider: "anthropic", retryable: true, @@ -1724,6 +1725,7 @@ describe("useChatStore", () => { expect(result.current.streamError).toEqual({ kind: "rate_limit", message: "Rate limit exceeded", + detail: "Image exceeds 5 MB maximum.", provider: "anthropic", retryable: true, statusCode: 429, @@ -1732,6 +1734,7 @@ describe("useChatStore", () => { expect(setChatErrorReason).toHaveBeenCalledWith(chatID, { kind: "rate_limit", message: "Rate limit exceeded", + detail: "Image exceeds 5 MB maximum.", provider: "anthropic", retryable: true, statusCode: 429, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts index 7e0a41afb7..9f12aac440 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts @@ -137,6 +137,19 @@ describe("deriveLiveStatus", () => { }); }); + it("passes provider detail through failed status", () => { + expect( + derive({ + streamError: makeStreamError({ + detail: "Image exceeds 5 MB maximum.", + }), + }), + ).toMatchObject({ + phase: "failed", + detail: "Image exceeds 5 MB maximum.", + }); + }); + it("tracks accumulated output while reconnecting", () => { expect( derive({ diff --git a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts index 17260ec15b..ad2ae0eba9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts @@ -36,6 +36,7 @@ export type LiveStatusModel = title: string; kind: string; message: string; + detail?: string; provider?: string; statusCode?: number; } & LiveStatusBase); @@ -87,6 +88,7 @@ const toFailedLiveStatus = ( title: getErrorTitle(error.kind, "error"), kind: error.kind, message: error.message, + ...(error.detail ? { detail: error.detail } : {}), provider: error.provider, statusCode: error.statusCode, }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index e89bffbd2d..5d2cda47eb 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -24,13 +24,17 @@ import type { RetryState } from "./types"; const normalizeChatDetailError = ( error: TypesGen.ChatStreamError | undefined, -): ChatDetailError => ({ - message: error?.message.trim() || "Chat processing failed.", - kind: error?.kind?.trim() || "generic", - provider: error?.provider?.trim() || undefined, - retryable: error?.retryable, - statusCode: error?.status_code, -}); +): ChatDetailError => { + const detail = error?.detail?.trim(); + return { + message: error?.message.trim() || "Chat processing failed.", + kind: error?.kind?.trim() || "generic", + provider: error?.provider?.trim() || undefined, + retryable: error?.retryable, + statusCode: error?.status_code, + ...(detail ? { detail } : {}), + }; +}; const normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => ({ attempt: Math.max(1, retry.attempt), diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts index 70ea5b0e57..0b4bdae584 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts @@ -97,6 +97,9 @@ describe("chatDetailErrorsEqual", () => { expect(chatDetailErrorsEqual(error, { ...error, statusCode: 500 })).toBe( false, ); + expect( + chatDetailErrorsEqual(error, { ...error, detail: "Bad image." }), + ).toBe(false); }); }); diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 2826e3ee9e..c2d41d5134 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -32,6 +32,7 @@ export type ChatProviderFailureKind = */ export type ChatDetailError = { message: string; + detail?: string; kind: ChatProviderFailureKind | (string & {}); provider?: string; retryable?: boolean; @@ -54,6 +55,7 @@ export const chatDetailErrorsEqual = ( return ( left.kind === right.kind && left.message === right.message && + left.detail === right.detail && left.provider === right.provider && left.retryable === right.retryable && left.statusCode === right.statusCode