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 "<status>: <message>" 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.
This commit is contained in:
vvzvlad
2026-06-17 18:33:44 +03:00
parent 551f975886
commit 022a1687a5

View File

@@ -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 `"<status>: <message>"` (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
// "<status>: <message>" (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;
}