From 022a1687a5d8af4b1772d66c8973222191c1c110 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Wed, 17 Jun 2026 18:33:44 +0300 Subject: [PATCH] fix(ai-chat): surface real provider stream errors in the agent UI The AI chat UI previously collapsed every non-403/503 failure into a generic "could not respond" message, hiding real provider errors such as OpenRouter HTTP 402 "requires more credits". The backend already forwards the real ": " via pipeUIMessageStreamToResponse onError, so the fix is client-side. - describeError now returns the provider message verbatim for any error that is not one of our own gating responses, so 402 (credits), 429 (rate limit) and similar causes are visible to the user. - Match gating responses by the NestJS JSON "statusCode" field instead of loose substring/word checks, so a provider message that merely contains "403"/"503"/"disabled" is no longer misclassified and hidden. - Add a providerDetail() helper that filters empty text and the opaque "An error occurred." / "Internal server error" placeholders, falling back to the generic message only then. No backend changes; no new i18n keys. --- .../ai-chat/components/chat-thread.tsx | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index e0503d0f..fda96ad8 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -158,20 +158,46 @@ export default function ChatThread({ /** * Turn a useChat error into a friendly inline message. The transport throws on - * non-2xx with the response text/status in the message, so we pattern-match the - * gating responses (403 chat disabled, 503 provider not configured) and fall - * back to a generic message otherwise — never a crash. + * non-2xx with the response text/status in the message, and stream failures + * arrive as `": "` (forwarded by the server's + * pipeUIMessageStreamToResponse onError). We keep the friendly mappings for the + * gating responses (403 chat disabled, 503 provider not configured) and + * otherwise surface the real provider message (e.g. 402 insufficient credits / + * 429 rate limit) so the actual cause is visible — never a crash. */ function describeError( error: Error, t: (key: string) => string, ): string { const msg = error.message ?? ""; - if (msg.includes("403") || /disabled/i.test(msg)) { + // Our own gating responses arrive PRE-stream as the raw JSON error body + // (NestJS default exception shape — no global filter overrides it), which + // carries a numeric "statusCode" field. Match that field, NOT a bare + // substring, so a provider stream error that merely contains "403"/"503" (or + // a word like "disabled") is never misclassified and its real cause hidden. + if (/"statusCode"\s*:\s*403\b/.test(msg)) { return t("AI chat is disabled for this workspace."); } - if (msg.includes("503") || /not configured/i.test(msg)) { + if (/"statusCode"\s*:\s*503\b/.test(msg)) { return t("The AI provider is not configured. Ask an administrator to set it up."); } - return t("The AI agent could not respond. Please try again."); + // Any other failure — including provider stream failures forwarded as + // ": " (402 credits, 429 rate limit, ...) — is surfaced + // verbatim so the real cause is visible. Fall back to a generic message only + // when there is no usable text or just an opaque placeholder. + return providerDetail(msg) ?? t("The AI agent could not respond. Please try again."); +} + +/** + * Extract a human-readable provider detail from a useChat error message, or + * null when there is nothing useful to show. Returns null for empty strings, + * the AI SDK's opaque "An error occurred." placeholder, and our own post-hijack + * "Internal server error" fallback, so the caller can use a friendly message. + */ +function providerDetail(msg: string): string | null { + const trimmed = msg.trim(); + if (!trimmed) return null; + if (/^an error occurred\.?$/i.test(trimmed)) return null; + if (/internal server error/i.test(trimmed)) return null; + return trimmed; }