From cc5accedaa1884a175ad6af109ba1b00e2f951c9 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Sun, 5 Apr 2026 20:20:21 +0200 Subject: [PATCH] fix: show human-readable error messages for API failures instead of returning to home Detect auth errors and show the API key modal, humanize raw SDK errors (e.g. "AI_APICallError: Bad Request") into actionable messages, and prevent session sync from wiping messages when a turn errors out. --- src/agent/agent.ts | 80 ++++++++++++++++++++++++++++++++++++++++------ src/types/index.ts | 1 + src/ui/app.tsx | 19 +++++++++-- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 9f6b3df..3074506 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -1,3 +1,4 @@ +import { APICallError } from "@ai-sdk/provider"; import { convertToBase64 } from "@ai-sdk/provider-utils"; import { type ModelMessage, stepCountIs, streamText, type ToolSet } from "ai"; import { @@ -1526,16 +1527,21 @@ export class Agent { continue; } - const msg = err instanceof Error ? err.message : String(err); + const authError = isAuthenticationError(err); + const friendly = humanizeApiError(err); notifyObserver(observer?.onError, { - message: `Error: ${msg}`, + message: friendly, timestamp: Date.now(), }); if (hasUsage(totalUsage)) { this.recordUsage(totalUsage, "message", runtime.modelId); } this.appendCompletedTurn(userModelMessage, turnMessages); - yield { type: "error", content: `Error: ${msg}` }; + yield { + type: "error", + content: friendly, + isAuthError: authError, + }; yield { type: "done" }; return; } finally { @@ -1746,12 +1752,17 @@ export class Agent { } case "error": { - const message = String(part.error); + const authError = isAuthenticationError(part.error); + const friendly = humanizeApiError(part.error); notifyObserver(observer?.onError, { - message, + message: friendly, timestamp: Date.now(), }); - yield { type: "error", content: message }; + yield { + type: "error", + content: friendly, + isAuthError: authError, + }; break; } @@ -1810,12 +1821,17 @@ export class Agent { continue; } - const msg = err instanceof Error ? err.message : String(err); + const authError = isAuthenticationError(err); + const friendly = humanizeApiError(err); notifyObserver(observer?.onError, { - message: `Error: ${msg}`, + message: friendly, timestamp: Date.now(), }); - yield { type: "error", content: `Error: ${msg}` }; + yield { + type: "error", + content: friendly, + isAuthError: authError, + }; if (assistantText.trim()) { this.appendCompletedTurn(userModelMessage, [{ role: "assistant", content: assistantText }]); } @@ -2357,3 +2373,49 @@ function isContextLimitError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /(context|token|prompt).*(limit|length|large|window|overflow)|too many tokens|maximum context/i.test(message); } + +function isAuthenticationError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /\b(401|403)\b|unauthori[sz]ed|invalid.*(api[_ ]?key|token|credential)|authentication failed|forbidden|access denied/i.test( + message, + ); +} + +const STATUS_MESSAGES: Record = { + 400: "The request was invalid. This may be caused by an unsupported parameter or model.", + 401: "Authentication failed. Your API key may be invalid or expired.", + 403: "Access denied. Your API key does not have permission for this request.", + 404: "The requested model or endpoint was not found. Check your model name and base URL.", + 408: "The request timed out. Please try again.", + 422: "The request could not be processed. Check your message format or parameters.", + 429: "Rate limit exceeded. Please wait a moment and try again.", + 500: "The API server encountered an internal error. Please try again later.", + 502: "The API server is temporarily unavailable. Please try again later.", + 503: "The API service is temporarily overloaded. Please try again later.", + 529: "The API service is overloaded. Please try again later.", +}; + +function humanizeApiError(error: unknown): string { + if (APICallError.isInstance(error)) { + const detail = extractResponseDetail(error.responseBody); + if (detail) return detail; + if (error.statusCode && STATUS_MESSAGES[error.statusCode]) { + return STATUS_MESSAGES[error.statusCode]; + } + } + + const raw = error instanceof Error ? error.message : String(error); + return raw.replace(/^AI_\w+Error:\s*/i, "").trim() || raw; +} + +function extractResponseDetail(body: string | undefined): string | null { + if (!body) return null; + try { + const parsed = JSON.parse(body); + const msg = parsed?.error?.message ?? parsed?.message ?? parsed?.detail; + if (typeof msg === "string" && msg.trim()) return msg.trim(); + } catch { + /* not JSON */ + } + return null; +} diff --git a/src/types/index.ts b/src/types/index.ts index e1c116a..ae635c4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -205,6 +205,7 @@ export interface StreamChunk { toolCalls?: ToolCall[]; toolCall?: ToolCall; toolResult?: ToolResult; + isAuthError?: boolean; } export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 5392cb1..8ddcb84 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -1401,7 +1401,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) }, []); const finalizeActiveTurn = useCallback( - ({ wasInterrupted = false }: { wasInterrupted?: boolean } = {}) => { + ({ wasInterrupted = false, hadError = false }: { wasInterrupted?: boolean; hadError?: boolean } = {}) => { const activeTurn = activeTurnRef.current; if (!activeTurn) { finishTurnProcessing(); @@ -1420,7 +1420,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) ]); } - if (!wasInterrupted) { + if (!wasInterrupted && !hadError) { if (activeTurn.kind === "local" && activeTurn.agent.getSessionId()) { setMessages((prev) => { const fresh = activeTurn.agent.getChatEntries(); @@ -1930,6 +1930,8 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setMessages((prev) => [...prev, buildUserEntry((displayText ?? text).trim(), { modeColor: color })]); setTimeout(scrollToBottom, 50); await new Promise((r) => setTimeout(r, 0)); + let turnHadError = false; + let turnHadAuthError = false; try { for await (const chunk of agent.processMessage(text.trim())) { if (isStale()) { @@ -1954,6 +1956,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) } break; case "error": + turnHadError = true; + if (chunk.isAuthError) { + turnHadAuthError = true; + } contentAccRef.current += `\n${chunk.content || "Unknown error"}`; setStreamContent(contentAccRef.current); break; @@ -1962,6 +1968,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) } } } catch { + turnHadError = true; if (!isStale()) { contentAccRef.current += "\nAn unexpected error occurred."; setStreamContent(contentAccRef.current); @@ -1973,8 +1980,14 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) return; } + if (turnHadAuthError) { + setApiKeyError("Your API key is invalid or expired. Please enter a new key."); + setShowApiKeyModal(true); + showApiKeyModalRef.current = true; + } + if (!isStale()) { - finalizeActiveTurn({ wasInterrupted }); + finalizeActiveTurn({ wasInterrupted, hadError: turnHadError }); } if (wasInterrupted) { interruptedRunIdRef.current = null;