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:
1 parent
2b7a0396bc
commit
cc5accedaa
3 files changed
+88
-12
No files matched your search
+71
-9
@@ -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;
|
||||
}
|
||||
@@ -205,6 +205,7 @@ export interface StreamChunk {
|
||||
toolCalls?: ToolCall[];
|
||||
toolCall?: ToolCall;
|
||||
toolResult?: ToolResult;
|
||||
isAuthError?: boolean;
|
||||
}
|
||||
|
||||
export type ReasoningEffort = "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
+16
-3
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user