From 2295e9d5be9eed99cc1f5c2922839394d41b9c1f Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:05:27 +1000 Subject: [PATCH] feat: surface upstream provider error details in chat callout (#24546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic HTTP 400 responses (e.g. "image exceeds 5 MB maximum") were collapsed in the chat UI to the generic headline "Anthropic returned an unexpected error (HTTP 400)." with no actionable detail — the upstream message survived to the processor log but was dropped before reaching the client. Add a new optional `Detail` field on `codersdk.ChatStreamError` that carries the upstream provider message alongside the existing normalized headline. The backend extracts `error.message` from `fantasy.ProviderError.ResponseBody` (the JSON envelope shared by Anthropic and OpenAI), falls back to the trimmed provider message when the body is absent or unparseable, and caps the result at 500 runes. The frontend threads `Detail` through `useChatStore`, `liveStatusModel`, and `ChatStatusCallout`, rendering it as a muted secondary line inside the existing `AlertDescription`. Before: image After: image ## Persistence `Detail` is **not** persisted — it disappears on refresh. Persisting it would require a DB change (today `chats.last_error` is a single nullable `TEXT` column), and the shape of persisted chat errors is worth a more deliberate rethink — e.g. promoting `last_error` to `JSONB` so we can also retain structured fields like `kind`, `statusCode`, `provider`, and `retryable` instead of only the normalized headline string. That's a bigger design discussion than this PR should carry. In the meantime, seeing the upstream error reason *immediately on failure* is already a large UX improvement over the status quo, and this PR gets us there without prejudicing the eventual persistence design. Tracking persistence in CODAGT-239. Closes CODAGT-235 --- coderd/x/chatd/chaterror/classify.go | 24 ++++++- coderd/x/chatd/chaterror/classify_test.go | 70 ++++++++++++++++++- coderd/x/chatd/chaterror/payload.go | 1 + coderd/x/chatd/chaterror/payload_test.go | 13 ++++ coderd/x/chatd/chaterror/provider_error.go | 40 +++++++++++ codersdk/chats.go | 3 + site/src/api/typesGenerated.ts | 5 ++ .../AgentsPage/AgentChatPageView.stories.tsx | 5 +- .../ChatConversation/ChatStatusCallout.tsx | 20 +++--- .../LiveStreamTail.stories.tsx | 33 ++++++++- .../chatStore.createStore.test.ts | 2 + .../ChatConversation/chatStore.test.tsx | 3 + .../ChatConversation/liveStatusModel.test.ts | 13 ++++ .../ChatConversation/liveStatusModel.ts | 2 + .../ChatConversation/useChatStore.ts | 18 +++-- .../utils/usageLimitMessage.test.ts | 3 + .../AgentsPage/utils/usageLimitMessage.ts | 2 + 17 files changed, 232 insertions(+), 25 deletions(-) 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