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.
This commit is contained in:
Ismail Pelaseyed committed 2026-04-05 20:20:21 +02:00
1 parent 2b7a0396bc
commit cc5accedaa
3 files changed
+88 -12

No files matched your search

+71 -9
View File
@@ -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<number, string> = {
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;
}
+1
View File
@@ -205,6 +205,7 @@ export interface StreamChunk {
toolCalls?: ToolCall[];
toolCall?: ToolCall;
toolResult?: ToolResult;
isAuthError?: boolean;
}
export type ReasoningEffort = "low" | "medium" | "high" | "xhigh";
+16 -3
View File
@@ -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;